From 789a6842188d3105dbe0bf867a4e05ecdc76144c Mon Sep 17 00:00:00 2001 From: Andrej Simurka Date: Wed, 5 Aug 2026 15:21:49 +0200 Subject: [PATCH 1/2] Used typed conversation items --- src/utils/conversation_compaction.py | 18 +++-- src/utils/conversations.py | 68 ++++++++++++---- tests/unit/utils/test_conversations.py | 103 +++++++++++++++++++++---- 3 files changed, 150 insertions(+), 39 deletions(-) diff --git a/src/utils/conversation_compaction.py b/src/utils/conversation_compaction.py index 5ee256cf7..4f88f0cb4 100644 --- a/src/utils/conversation_compaction.py +++ b/src/utils/conversation_compaction.py @@ -65,6 +65,7 @@ ) from utils.conversations import ( append_turn_items_to_conversation, + build_add_items_request, get_all_conversation_items, ) from utils.token_estimator import ( @@ -275,16 +276,17 @@ async def _write_summary_marker( summary_text: str, ) -> None: """Write the summary into the conversation as a recognizable marker message.""" - marker_item: dict[str, Any] = { - "type": "message", - "role": "user", - "content": [ - {"type": "input_text", "text": f"{MARKER_SENTINEL} {summary_text}"} - ], - } await client.conversations.items.create( conversation_id, - items=[marker_item], + add_items_request=build_add_items_request( + [ + { + "type": "message", + "role": "user", + "content": f"{MARKER_SENTINEL} {summary_text}", + } + ] + ), ) diff --git a/src/utils/conversations.py b/src/utils/conversations.py index 5b14a25d1..fd1891a36 100644 --- a/src/utils/conversations.py +++ b/src/utils/conversations.py @@ -6,8 +6,9 @@ from typing import Any, Literal, Optional, cast from fastapi import HTTPException -from ogx_api import OpenAIResponseMessage, OpenAIResponseOutput +from ogx_api import OpenAIResponseOutput from ogx_client import APIConnectionError, APIStatusError, AsyncOgxClient +from ogx_client.models.add_items_request import AddItemsRequest from ogx_client.models.open_ai_response_input_function_tool_call_output import ( OpenAIResponseInputFunctionToolCallOutput as FunctionCallOutput, ) @@ -47,6 +48,7 @@ from ogx_client.models.open_ai_response_output_message_web_search_tool_call import ( OpenAIResponseOutputMessageWebSearchToolCall as WebSearchCall, ) +from pydantic import ValidationError from constants import DEFAULT_RAG_TOOL from models.api.responses.error import ( @@ -66,6 +68,40 @@ type FunctionCallOutputContent = str | list[FunctionCallOutputPart] +def to_conversation_item(data: dict[str, Any]) -> Optional[ConversationItem]: + """Attempt to parse a raw dict into a oneOf wrapper. + + Parameters: + data: Raw dict to parse. + + Returns: + ConversationItem if parsing succeeds, + None otherwise. + """ + try: + return ConversationItem.from_dict(data) + except (ValidationError, ValueError): + return None + + +def build_add_items_request(items: Sequence[dict[str, Any]]) -> AddItemsRequest: + """Build an ``AddItemsRequest`` from conversation items. + + Parameters: + items: Conversation items to append. + + Returns: + Request body for items.create. + """ + return AddItemsRequest( + items=[ + conversation_item + for item in items + if (conversation_item := to_conversation_item(item)) is not None + ] + ) + + def _extract_text_from_content(content: Any) -> str: """Extract text content from message content. @@ -503,21 +539,17 @@ async def append_turn_items_to_conversation( llm_output: Output from the LLM: a list of OpenAIResponseOutput. """ if isinstance(user_input, str): - user_message = OpenAIResponseMessage( - role="user", - content=user_input, - ) - user_items = [user_message.model_dump()] + items: list[dict[str, Any]] = [ + {"type": "message", "role": "user", "content": user_input} + ] else: - user_items = [item.model_dump() for item in user_input] + items = [item.model_dump(exclude_none=True) for item in user_input] - output_items = [item.model_dump() for item in llm_output] - - items = user_items + output_items + items.extend(item.model_dump(exclude_none=True) for item in llm_output) try: await client.conversations.items.create( conversation_id, - items=items, + add_items_request=build_add_items_request(items), ) except APIConnectionError as e: error_response = ServiceUnavailableResponse( @@ -590,10 +622,16 @@ async def append_turn_to_conversation( try: await client.conversations.items.create( conversation_id, - items=[ - {"type": "message", "role": "user", "content": user_message}, - {"type": "message", "role": "assistant", "content": assistant_message}, - ], + add_items_request=build_add_items_request( + [ + {"type": "message", "role": "user", "content": user_message}, + { + "type": "message", + "role": "assistant", + "content": assistant_message, + }, + ] + ), ) except APIConnectionError as e: error_response = ServiceUnavailableResponse( diff --git a/tests/unit/utils/test_conversations.py b/tests/unit/utils/test_conversations.py index 13cfaf1cd..1730d20d2 100644 --- a/tests/unit/utils/test_conversations.py +++ b/tests/unit/utils/test_conversations.py @@ -7,6 +7,7 @@ from fastapi import HTTPException from ogx_api import OpenAIResponseMessage from ogx_client import APIConnectionError, APIStatusError +from ogx_client.models.add_items_request import AddItemsRequest from ogx_client.models.open_ai_response_input_function_tool_call_output import ( OpenAIResponseInputFunctionToolCallOutput as FunctionCallOutput, ) @@ -30,8 +31,10 @@ _function_call_output_to_str, append_turn_items_to_conversation, append_turn_to_conversation, + build_add_items_request, build_conversation_turns_from_items, get_all_conversation_items, + to_conversation_item, ) # Default conversation start time for tests @@ -809,6 +812,72 @@ def test_legacy_conversation_without_metadata(self, mocker: MockerFixture) -> No assert turn.completed_at == "2024-01-01T10:00:00Z" +class TestToConversationItem: + """Tests for to_conversation_item.""" + + def test_parses_message_dict(self) -> None: + """Valid message dict becomes a conversation item.""" + item = to_conversation_item( + {"type": "message", "role": "user", "content": "Hello"} + ) + assert item is not None + assert item.type == "message" + assert item.role == "user" + assert item.content == "Hello" + + def test_parses_model_dump_payload(self) -> None: + """model_dump() of an ogx_api message is accepted.""" + payload = OpenAIResponseMessage( + type="message", + role="assistant", + content="Hi", + ).model_dump(exclude_none=True) + item = to_conversation_item(payload) + assert item is not None + assert item.role == "assistant" + assert item.content == "Hi" + + def test_returns_none_for_invalid_payload(self) -> None: + """Unrecognized oneOf payload returns None instead of raising.""" + assert to_conversation_item({"type": "not_a_real_variant"}) is None + + +class TestBuildAddItemsRequest: + """Tests for build_add_items_request.""" + + def test_builds_request_from_message_dicts(self) -> None: + """Dicts are validated into AddItemsRequest items.""" + request = build_add_items_request( + [ + {"type": "message", "role": "user", "content": "Hello"}, + { + "type": "message", + "role": "assistant", + "content": "I cannot help with that", + }, + ] + ) + assert isinstance(request, AddItemsRequest) + items = list(request) + assert len(items) == 2 + assert items[0].type == "message" and items[0].role == "user" + assert items[0].content == "Hello" + assert items[1].type == "message" and items[1].role == "assistant" + assert items[1].content == "I cannot help with that" + + def test_skips_invalid_dicts(self) -> None: + """Invalid payloads are filtered out of the request.""" + request = build_add_items_request( + [ + {"type": "message", "role": "user", "content": "ok"}, + {"type": "not_a_real_variant"}, + ] + ) + items = list(request) + assert len(items) == 1 + assert items[0].content == "ok" + + class TestAppendTurnItemsToConversation: # pylint: disable=too-few-public-methods """Tests for append_turn_items_to_conversation function.""" @@ -835,12 +904,14 @@ async def test_appends_user_input_and_llm_output( mock_client.conversations.items.create.assert_called_once() call_args = mock_client.conversations.items.create.call_args assert call_args[0][0] == "conv-123" - items = call_args[1]["items"] + request = call_args[1]["add_items_request"] + assert isinstance(request, AddItemsRequest) + items = list(request) assert len(items) == 2 - assert items[0]["type"] == "message" and items[0]["role"] == "user" - assert items[0]["content"] == "Hello" - assert items[1]["type"] == "message" and items[1]["role"] == "assistant" - assert items[1]["content"] == "I cannot help with that" + assert items[0].type == "message" and items[0].role == "user" + assert items[0].content == "Hello" + assert items[1].type == "message" and items[1].role == "assistant" + assert items[1].content == "I cannot help with that" class TestAppendTurnToConversation: # pylint: disable=too-few-public-methods @@ -861,17 +932,17 @@ async def test_appends_user_and_assistant_messages( assistant_message="I cannot help with that", ) - mock_client.conversations.items.create.assert_called_once_with( - "conv-123", - items=[ - {"type": "message", "role": "user", "content": "Hello"}, - { - "type": "message", - "role": "assistant", - "content": "I cannot help with that", - }, - ], - ) + mock_client.conversations.items.create.assert_called_once() + call_args = mock_client.conversations.items.create.call_args + assert call_args[0][0] == "conv-123" + request = call_args[1]["add_items_request"] + assert isinstance(request, AddItemsRequest) + items = list(request) + assert len(items) == 2 + assert items[0].type == "message" and items[0].role == "user" + assert items[0].content == "Hello" + assert items[1].type == "message" and items[1].role == "assistant" + assert items[1].content == "I cannot help with that" class TestGetAllConversationItems: From 6ce7055e857b8893680de89e09e7208e659e14d6 Mon Sep 17 00:00:00 2001 From: Andrej Simurka Date: Thu, 6 Aug 2026 10:34:39 +0200 Subject: [PATCH 2/2] Fixed ruff --- tests/integration/endpoints/test_model_list.py | 5 ++++- tests/integration/endpoints/test_responses_integration.py | 5 ++++- .../endpoints/test_streaming_query_integration.py | 5 ++++- tests/unit/app/endpoints/test_rags.py | 3 ++- tests/unit/app/endpoints/test_vector_stores.py | 3 ++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/integration/endpoints/test_model_list.py b/tests/integration/endpoints/test_model_list.py index 5c35a4272..362100453 100644 --- a/tests/integration/endpoints/test_model_list.py +++ b/tests/integration/endpoints/test_model_list.py @@ -13,7 +13,10 @@ from authentication.interface import AuthTuple from configuration import AppConfig from models.api.requests import ModelFilter -from tests.integration.conftest import make_openai_model, make_openai_models_list_response +from tests.integration.conftest import ( + make_openai_model, + make_openai_models_list_response, +) @pytest.fixture(name="mock_ogx_client") diff --git a/tests/integration/endpoints/test_responses_integration.py b/tests/integration/endpoints/test_responses_integration.py index fd37d1b8b..bfc58807a 100644 --- a/tests/integration/endpoints/test_responses_integration.py +++ b/tests/integration/endpoints/test_responses_integration.py @@ -21,8 +21,11 @@ from models.api.responses.successful import ResponsesResponse from models.common.moderation import ShieldModerationBlocked from models.common.responses.contexts import ResponsesContext -from tests.integration.conftest import make_openai_model, make_openai_models_list_response from models.database.conversations import UserConversation, UserTurn +from tests.integration.conftest import ( + make_openai_model, + make_openai_models_list_response, +) MOCK_AUTH: AuthTuple = ( "00000000-0000-0000-0000-000", diff --git a/tests/integration/endpoints/test_streaming_query_integration.py b/tests/integration/endpoints/test_streaming_query_integration.py index d9d204c62..ceeda3cc5 100644 --- a/tests/integration/endpoints/test_streaming_query_integration.py +++ b/tests/integration/endpoints/test_streaming_query_integration.py @@ -14,7 +14,10 @@ from configuration import AppConfig from models.api.requests import QueryRequest from models.common.query import Attachment -from tests.integration.conftest import make_openai_model, make_openai_models_list_response +from tests.integration.conftest import ( + make_openai_model, + make_openai_models_list_response, +) @pytest.fixture(name="mock_streaming_ogx_client") diff --git a/tests/unit/app/endpoints/test_rags.py b/tests/unit/app/endpoints/test_rags.py index 50338d6b5..15c2d8350 100644 --- a/tests/unit/app/endpoints/test_rags.py +++ b/tests/unit/app/endpoints/test_rags.py @@ -1,7 +1,8 @@ """Unit tests for the /rags REST API endpoints.""" +from collections.abc import Iterator from pathlib import Path -from typing import Any, Iterator +from typing import Any import pytest from fastapi import HTTPException, Request, status diff --git a/tests/unit/app/endpoints/test_vector_stores.py b/tests/unit/app/endpoints/test_vector_stores.py index 9a5ec735e..efb004c26 100644 --- a/tests/unit/app/endpoints/test_vector_stores.py +++ b/tests/unit/app/endpoints/test_vector_stores.py @@ -2,7 +2,8 @@ # pylint: disable=too-many-lines -from typing import Any, Iterator +from collections.abc import Iterator +from typing import Any import pytest from fastapi import HTTPException, Request, status