diff --git a/docs/openapi.json b/docs/openapi.json index c0057989..34492061 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -123,6 +123,52 @@ } } }, + "/v1/tools": { + "get": { + "tags": [ + "tools" + ], + "summary": "Tools Endpoint Handler", + "description": "Handle requests to the /tools endpoint.\n\nProcess GET requests to the /tools endpoint, returning a consolidated list of\navailable tools from all configured MCP servers.\n\nRaises:\n HTTPException: If unable to connect to the Llama Stack server or if\n tool retrieval fails for any reason.\n\nReturns:\n ToolsResponse: An object containing the consolidated list of available tools\n with metadata including tool name, description, parameters, and server source.", + "operationId": "tools_endpoint_handler_v1_tools_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolsResponse" + }, + "example": { + "tools": [ + { + "identifier": "", + "description": "", + "parameters": [ + { + "name": "", + "description": "", + "parameter_type": "", + "required": "True/False", + "default": "null" + } + ], + "provider_id": "", + "toolgroup_id": "", + "server_source": "", + "type": "tool" + } + ] + } + } + } + }, + "500": { + "description": "Connection to Llama Stack is broken or MCP server error" + } + } + } + }, "/v1/shields": { "get": { "tags": [ @@ -945,6 +991,87 @@ } } } + }, + "put": { + "tags": [ + "conversations_v2" + ], + "summary": "Update Conversation Endpoint Handler", + "description": "Handle request to update a conversation topic summary by ID.", + "operationId": "update_conversation_endpoint_handler_v2_conversations__conversation_id__put", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Conversation Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationUpdateResponse" + } + } + }, + "conversation_id": "123e4567-e89b-12d3-a456-426614174000", + "success": true, + "message": "Topic summary updated successfully" + }, + "400": { + "description": "Missing or invalid credentials provided by client", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + }, + "401": { + "description": "Unauthorized: Invalid or missing Bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + }, + "404": { + "detail": { + "response": "Conversation not found", + "cause": "The specified conversation ID does not exist." + }, + "description": "Not Found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } } }, "/readiness": { @@ -1114,8 +1241,10 @@ "get_conversation", "list_conversations", "delete_conversation", + "update_conversation", "feedback", "get_models", + "get_tools", "get_shields", "get_metrics", "get_config", @@ -1693,6 +1822,63 @@ } ] }, + "ConversationUpdateRequest": { + "properties": { + "topic_summary": { + "type": "string", + "maxLength": 1000, + "minLength": 1, + "title": "Topic Summary", + "description": "The new topic summary for the conversation", + "examples": [ + "Discussion about machine learning algorithms" + ] + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "topic_summary" + ], + "title": "ConversationUpdateRequest", + "description": "Model representing a request to update a conversation topic summary.\n\nAttributes:\n topic_summary: The new topic summary for the conversation.\n\nExample:\n ```python\n update_request = ConversationUpdateRequest(\n topic_summary=\"Discussion about machine learning algorithms\"\n )\n ```" + }, + "ConversationUpdateResponse": { + "properties": { + "conversation_id": { + "type": "string", + "title": "Conversation Id", + "description": "The conversation ID (UUID) that was updated", + "examples": [ + "123e4567-e89b-12d3-a456-426614174000" + ] + }, + "success": { + "type": "boolean", + "title": "Success", + "description": "Whether the update was successful", + "examples": [ + true + ] + }, + "message": { + "type": "string", + "title": "Message", + "description": "A message about the update result", + "examples": [ + "Topic summary updated successfully" + ] + } + }, + "type": "object", + "required": [ + "conversation_id", + "success", + "message" + ], + "title": "ConversationUpdateResponse", + "description": "Model representing a response for updating a conversation topic summary.\n\nAttributes:\n conversation_id: The conversation ID (UUID) that was updated.\n success: Whether the update was successful.\n message: A message about the update result.\n\nExample:\n ```python\n update_response = ConversationUpdateResponse(\n conversation_id=\"123e4567-e89b-12d3-a456-426614174000\",\n success=True,\n message=\"Topic summary updated successfully\",\n )\n ```" + }, "ConversationsListResponse": { "properties": { "conversations": { @@ -3170,6 +3356,45 @@ "title": "ToolCall", "description": "Model representing a tool call made during response generation." }, + "ToolsResponse": { + "properties": { + "tools": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Tools", + "description": "List of tools available from all configured MCP servers and built-in toolgroups", + "examples": [ + [ + { + "description": "Read contents of a file from the filesystem", + "identifier": "filesystem_read", + "parameters": [ + { + "description": "Path to the file to read", + "name": "path", + "parameter_type": "string", + "required": true + } + ], + "provider_id": "model-context-protocol", + "server_source": "http://localhost:3000", + "toolgroup_id": "filesystem-tools", + "type": "tool" + } + ] + ] + } + }, + "type": "object", + "required": [ + "tools" + ], + "title": "ToolsResponse", + "description": "Model representing a response to tools request." + }, "UnauthorizedResponse": { "properties": { "detail": { diff --git a/src/app/endpoints/conversations_v2.py b/src/app/endpoints/conversations_v2.py index ad3529c8..7ce0ac7c 100644 --- a/src/app/endpoints/conversations_v2.py +++ b/src/app/endpoints/conversations_v2.py @@ -10,9 +10,11 @@ from configuration import configuration from models.cache_entry import CacheEntry from models.config import Action +from models.requests import ConversationUpdateRequest from models.responses import ( ConversationDeleteResponse, ConversationResponse, + ConversationUpdateResponse, ConversationsListResponseV2, UnauthorizedResponse, ) @@ -89,6 +91,28 @@ } } +conversation_update_responses: dict[int | str, dict[str, Any]] = { + 200: { + "conversation_id": "123e4567-e89b-12d3-a456-426614174000", + "success": True, + "message": "Topic summary updated successfully", + }, + 400: { + "description": "Missing or invalid credentials provided by client", + "model": UnauthorizedResponse, + }, + 401: { + "description": "Unauthorized: Invalid or missing Bearer token", + "model": UnauthorizedResponse, + }, + 404: { + "detail": { + "response": "Conversation not found", + "cause": "The specified conversation ID does not exist.", + } + }, +} + @router.get("/conversations", responses=conversations_list_responses) @authorize(Action.LIST_CONVERSATIONS) @@ -207,6 +231,56 @@ async def delete_conversation_endpoint_handler( ) +@router.put("/conversations/{conversation_id}", responses=conversation_update_responses) +@authorize(Action.UPDATE_CONVERSATION) +async def update_conversation_endpoint_handler( + conversation_id: str, + update_request: ConversationUpdateRequest, + auth: Any = Depends(get_auth_dependency()), +) -> ConversationUpdateResponse: + """Handle request to update a conversation topic summary by ID.""" + check_configuration_loaded(configuration) + check_valid_conversation_id(conversation_id) + + user_id = auth[0] + logger.info( + "Updating topic summary for conversation %s for user %s", + conversation_id, + user_id, + ) + + skip_userid_check = auth[2] + + if configuration.conversation_cache is None: + logger.warning("Conversation cache is not configured") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "response": "Conversation cache is not configured", + "cause": "Conversation cache is not configured", + }, + ) + + check_conversation_existence(user_id, conversation_id) + + # Update the topic summary in the cache + configuration.conversation_cache.set_topic_summary( + user_id, conversation_id, update_request.topic_summary, skip_userid_check + ) + + logger.info( + "Successfully updated topic summary for conversation %s for user %s", + conversation_id, + user_id, + ) + + return ConversationUpdateResponse( + conversation_id=conversation_id, + success=True, + message="Topic summary updated successfully", + ) + + def check_valid_conversation_id(conversation_id: str) -> None: """Check validity of conversation ID format.""" if not check_suid(conversation_id): diff --git a/src/models/config.py b/src/models/config.py index 8f68e29e..544cefa8 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -357,6 +357,9 @@ class Action(str, Enum): # Access the conversation delete endpoint DELETE_CONVERSATION = "delete_conversation" + + # Access the conversation update endpoint + UPDATE_CONVERSATION = "update_conversation" FEEDBACK = "feedback" GET_MODELS = "get_models" GET_TOOLS = "get_tools" diff --git a/src/models/requests.py b/src/models/requests.py index fcfaecca..6e8dc0e1 100644 --- a/src/models/requests.py +++ b/src/models/requests.py @@ -410,3 +410,29 @@ class FeedbackStatusUpdateRequest(BaseModel): def get_value(self) -> bool: """Return the value of the status attribute.""" return self.status + + +class ConversationUpdateRequest(BaseModel): + """Model representing a request to update a conversation topic summary. + + Attributes: + topic_summary: The new topic summary for the conversation. + + Example: + ```python + update_request = ConversationUpdateRequest( + topic_summary="Discussion about machine learning algorithms" + ) + ``` + """ + + topic_summary: str = Field( + ..., + description="The new topic summary for the conversation", + examples=["Discussion about machine learning algorithms"], + min_length=1, + max_length=1000, + ) + + # Reject unknown fields + model_config = {"extra": "forbid"} diff --git a/src/models/responses.py b/src/models/responses.py index 80b97053..e89f0b36 100644 --- a/src/models/responses.py +++ b/src/models/responses.py @@ -865,3 +865,38 @@ class FeedbackStatusUpdateResponse(BaseModel): ] } } + + +class ConversationUpdateResponse(BaseModel): + """Model representing a response for updating a conversation topic summary. + + Attributes: + conversation_id: The conversation ID (UUID) that was updated. + success: Whether the update was successful. + message: A message about the update result. + + Example: + ```python + update_response = ConversationUpdateResponse( + conversation_id="123e4567-e89b-12d3-a456-426614174000", + success=True, + message="Topic summary updated successfully", + ) + ``` + """ + + conversation_id: str = Field( + ..., + description="The conversation ID (UUID) that was updated", + examples=["123e4567-e89b-12d3-a456-426614174000"], + ) + success: bool = Field( + ..., + description="Whether the update was successful", + examples=[True], + ) + message: str = Field( + ..., + description="A message about the update result", + examples=["Topic summary updated successfully"], + ) diff --git a/tests/integration/test_openapi_json.py b/tests/integration/test_openapi_json.py index 1b5c6932..9930b2fd 100644 --- a/tests/integration/test_openapi_json.py +++ b/tests/integration/test_openapi_json.py @@ -77,6 +77,22 @@ def test_servers_section_present(spec: dict): "delete", {"200", "400", "401", "404", "503", "422"}, ), + ("/v2/conversations", "get", {"200"}), + ( + "/v2/conversations/{conversation_id}", + "get", + {"200", "400", "401", "404", "422"}, + ), + ( + "/v2/conversations/{conversation_id}", + "delete", + {"200", "400", "401", "404", "422"}, + ), + ( + "/v2/conversations/{conversation_id}", + "put", + {"200", "400", "401", "404", "422"}, + ), ("/readiness", "get", {"200", "503"}), ("/liveness", "get", {"200"}), ("/authorized", "post", {"200", "400", "401", "403"}), diff --git a/tests/unit/app/endpoints/test_conversations_v2.py b/tests/unit/app/endpoints/test_conversations_v2.py index 21cb71b5..0448c220 100644 --- a/tests/unit/app/endpoints/test_conversations_v2.py +++ b/tests/unit/app/endpoints/test_conversations_v2.py @@ -2,9 +2,24 @@ """Unit tests for the /conversations REST API endpoints.""" -from app.endpoints.conversations_v2 import transform_chat_message +from unittest.mock import Mock +import pytest +from fastapi import HTTPException, status +from app.endpoints.conversations_v2 import ( + transform_chat_message, + update_conversation_endpoint_handler, + check_valid_conversation_id, + check_conversation_existence, +) from models.cache_entry import CacheEntry +from models.requests import ConversationUpdateRequest +from models.responses import ConversationUpdateResponse +from tests.unit.utils.auth_helpers import mock_authorization_resolvers + +MOCK_AUTH = ("mock_user_id", "mock_username", False, "mock_token") +VALID_CONVERSATION_ID = "123e4567-e89b-12d3-a456-426614174000" +INVALID_CONVERSATION_ID = "invalid-id" def test_transform_message() -> None: @@ -42,3 +57,168 @@ def test_transform_message() -> None: message2 = transformed["messages"][1] assert message2["type"] == "assistant" assert message2["content"] == "response" + + +@pytest.fixture +def mock_configuration(): + """Mock configuration with conversation cache.""" + mock_config = Mock() + mock_cache = Mock() + mock_config.conversation_cache = mock_cache + return mock_config + + +class TestCheckValidConversationId: + """Test cases for the check_valid_conversation_id function.""" + + def test_valid_conversation_id(self, mocker): + """Test with a valid conversation ID.""" + mocker.patch("app.endpoints.conversations_v2.check_suid", return_value=True) + # Should not raise an exception + check_valid_conversation_id(VALID_CONVERSATION_ID) + + def test_invalid_conversation_id(self, mocker): + """Test with an invalid conversation ID.""" + mocker.patch("app.endpoints.conversations_v2.check_suid", return_value=False) + + with pytest.raises(HTTPException) as exc_info: + check_valid_conversation_id(INVALID_CONVERSATION_ID) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert "Invalid conversation ID format" in exc_info.value.detail["response"] + + +class TestCheckConversationExistence: + """Test cases for the check_conversation_existence function.""" + + def test_conversation_exists(self, mocker, mock_configuration): + """Test when conversation exists.""" + mock_configuration.conversation_cache.list.return_value = [ + Mock(conversation_id=VALID_CONVERSATION_ID) + ] + mocker.patch("app.endpoints.conversations_v2.configuration", mock_configuration) + + # Should not raise an exception + check_conversation_existence("user_id", VALID_CONVERSATION_ID) + + def test_conversation_not_exists(self, mocker, mock_configuration): + """Test when conversation does not exist.""" + mock_configuration.conversation_cache.list.return_value = [] + mocker.patch("app.endpoints.conversations_v2.configuration", mock_configuration) + + with pytest.raises(HTTPException) as exc_info: + check_conversation_existence("user_id", VALID_CONVERSATION_ID) + + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + assert "Conversation not found" in exc_info.value.detail["response"] + + +class TestUpdateConversationEndpoint: + """Test cases for the PUT /conversations/{conversation_id} endpoint.""" + + @pytest.mark.asyncio + async def test_configuration_not_loaded(self, mocker): + """Test the endpoint when configuration is not loaded.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.conversations_v2.configuration", None) + + update_request = ConversationUpdateRequest(topic_summary="New topic summary") + + with pytest.raises(HTTPException) as exc_info: + await update_conversation_endpoint_handler( + conversation_id=VALID_CONVERSATION_ID, + update_request=update_request, + auth=MOCK_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + + @pytest.mark.asyncio + async def test_invalid_conversation_id_format(self, mocker, mock_configuration): + """Test the endpoint with an invalid conversation ID format.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.conversations_v2.configuration", mock_configuration) + mocker.patch("app.endpoints.conversations_v2.check_suid", return_value=False) + + update_request = ConversationUpdateRequest(topic_summary="New topic summary") + + with pytest.raises(HTTPException) as exc_info: + await update_conversation_endpoint_handler( + conversation_id=INVALID_CONVERSATION_ID, + update_request=update_request, + auth=MOCK_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST + assert "Invalid conversation ID format" in exc_info.value.detail["response"] + + @pytest.mark.asyncio + async def test_conversation_cache_not_configured(self, mocker): + """Test the endpoint when conversation cache is not configured.""" + mock_authorization_resolvers(mocker) + mock_config = Mock() + mock_config.conversation_cache = None + mocker.patch("app.endpoints.conversations_v2.configuration", mock_config) + mocker.patch("app.endpoints.conversations_v2.check_suid", return_value=True) + + update_request = ConversationUpdateRequest(topic_summary="New topic summary") + + with pytest.raises(HTTPException) as exc_info: + await update_conversation_endpoint_handler( + conversation_id=VALID_CONVERSATION_ID, + update_request=update_request, + auth=MOCK_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + assert ( + "Conversation cache is not configured" in exc_info.value.detail["response"] + ) + + @pytest.mark.asyncio + async def test_conversation_not_found(self, mocker, mock_configuration): + """Test the endpoint when conversation does not exist.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.conversations_v2.configuration", mock_configuration) + mocker.patch("app.endpoints.conversations_v2.check_suid", return_value=True) + mock_configuration.conversation_cache.list.return_value = [] + + update_request = ConversationUpdateRequest(topic_summary="New topic summary") + + with pytest.raises(HTTPException) as exc_info: + await update_conversation_endpoint_handler( + conversation_id=VALID_CONVERSATION_ID, + update_request=update_request, + auth=MOCK_AUTH, + ) + + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + assert "Conversation not found" in exc_info.value.detail["response"] + + @pytest.mark.asyncio + async def test_successful_update(self, mocker, mock_configuration): + """Test successful topic summary update.""" + mock_authorization_resolvers(mocker) + mocker.patch("app.endpoints.conversations_v2.configuration", mock_configuration) + mocker.patch("app.endpoints.conversations_v2.check_suid", return_value=True) + mock_configuration.conversation_cache.list.return_value = [ + Mock(conversation_id=VALID_CONVERSATION_ID) + ] + + update_request = ConversationUpdateRequest(topic_summary="New topic summary") + + response = await update_conversation_endpoint_handler( + conversation_id=VALID_CONVERSATION_ID, + update_request=update_request, + auth=MOCK_AUTH, + ) + + assert isinstance(response, ConversationUpdateResponse) + assert response.conversation_id == VALID_CONVERSATION_ID + assert response.success is True + assert response.message == "Topic summary updated successfully" + + # Verify that set_topic_summary was called + mock_configuration.conversation_cache.set_topic_summary.assert_called_once_with( + "mock_user_id", VALID_CONVERSATION_ID, "New topic summary", False + )