Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/app/endpoints/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
)
from models.config import Action
from utils.endpoints import check_configuration_loaded
from utils.ogx_serialization import dump_ogx_model
from utils.query import handle_known_apistatus_errors
from utils.suid import check_suid_prompt

Expand Down Expand Up @@ -136,7 +137,7 @@ async def create_prompt_handler(
client = AsyncOgxClientHolder().get_client()
payload = body.model_dump(exclude_none=True)
created = await client.prompts.create(**payload)
return PromptResourceResponse.model_validate(created.model_dump())
return PromptResourceResponse.model_validate(dump_ogx_model(created))
except ApiException as e:
if not e.status:
logger.error("Unable to connect to Llama Stack: %s", e)
Expand Down Expand Up @@ -185,7 +186,7 @@ async def list_prompts_handler(
try:
client = AsyncOgxClientHolder().get_client()
items = await client.prompts.list()
data = [PromptResourceResponse.model_validate(p.model_dump()) for p in items]
data = [PromptResourceResponse.model_validate(dump_ogx_model(p)) for p in items]
return PromptsListResponse(data=data)
except ApiException as e:
if not e.status:
Expand Down Expand Up @@ -246,7 +247,7 @@ async def get_prompt_handler(
try:
client = AsyncOgxClientHolder().get_client()
retrieved = await client.prompts.retrieve(prompt_id, version=version)
return PromptResourceResponse.model_validate(retrieved.model_dump())
return PromptResourceResponse.model_validate(dump_ogx_model(retrieved))
except (BadRequestError, ValueError) as e:
logger.error("Prompt not found: %s", e)
response = NotFoundResponse(resource="prompt", resource_id=prompt_id)
Expand Down Expand Up @@ -316,7 +317,7 @@ async def update_prompt_handler(
client = AsyncOgxClientHolder().get_client()
payload = body.model_dump(exclude_none=True, exclude_unset=True)
updated = await client.prompts.update(prompt_id, **payload)
return PromptResourceResponse.model_validate(updated.model_dump())
return PromptResourceResponse.model_validate(dump_ogx_model(updated))
except (BadRequestError, ValueError) as e:
logger.error("Prompt update failed: %s", e)
response = NotFoundResponse(resource="prompt", resource_id=prompt_id)
Expand Down
3 changes: 2 additions & 1 deletion src/app/endpoints/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
)
from models.config import Action
from utils.endpoints import check_configuration_loaded
from utils.ogx_serialization import dump_ogx_model

logger = get_logger(__name__)
router = APIRouter(tags=["providers"])
Expand Down Expand Up @@ -164,7 +165,7 @@ async def get_provider_endpoint_handler(
try:
client = AsyncOgxClientHolder().get_client()
provider = await client.providers.retrieve(provider_id)
return ProviderResponse(**provider.model_dump())
return ProviderResponse(**dump_ogx_model(provider))

except (BadRequestError, ValueError) as e:
# Server mode raises BadRequestError; library mode raises ValueError.
Expand Down
11 changes: 8 additions & 3 deletions src/app/endpoints/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
)
from utils.mcp_headers import mcp_headers_dependency
from utils.mcp_oauth_probe import check_mcp_auth
from utils.ogx_serialization import dump_ogx_model
from utils.prompts import get_system_prompt
from utils.query import (
consume_query_tokens,
Expand Down Expand Up @@ -711,7 +712,7 @@ def _sanitize_response_dict(
response object before it is forwarded to the client.

Args:
response_dict: Mutable dict produced by ``model_dump`` on a response
response_dict: Mutable dict produced by ``dump_ogx_model`` on a response
object. Modified in-place.
configured_mcp_labels: Set of ``server_label`` values that identify
server-deployed MCP servers.
Expand Down Expand Up @@ -886,7 +887,7 @@ async def response_generator(
):
continue

chunk_dict = chunk.model_dump(exclude_none=True, by_alias=True)
chunk_dict = dump_ogx_model(chunk)

# Create own sequence number for chunks to maintain order
chunk_dict["sequence_number"] = sequence_number
Expand Down Expand Up @@ -1164,7 +1165,11 @@ async def handle_non_streaming_response(
output_text,
)
configured_mcp_labels = {s.name for s in configuration.mcp_servers}
response_dict = api_response.model_dump(exclude_none=True)
response_dict = (
api_response.model_dump(exclude_none=True)
if context.moderation_result.decision == "blocked"
else dump_ogx_model(api_response)
)
_sanitize_response_dict(
response_dict,
configured_mcp_labels,
Expand Down
19 changes: 19 additions & 0 deletions src/utils/ogx_serialization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Serialization helpers for ``ogx_client`` models."""

from typing import Any

from ogx_client.api_client import ApiClient

_OGX_SERIALIZER = ApiClient()


def dump_ogx_model(obj: Any) -> Any:
"""Dump an ogx_client model to a JSON-safe structure.

Args:
obj: An ogx_client model instance.

Returns:
A JSON-serializable dict, list, or primitive produced from obj.
"""
return _OGX_SERIALIZER.sanitize_for_serialization(obj) # type: ignore[no-untyped-call]
139 changes: 85 additions & 54 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
import pytest
from fastapi import Request, Response
from fastapi.testclient import TestClient
from ogx_api.openai_responses import OpenAIResponseObject
from ogx_client.models.list_models_v1_models_get200_response import (
ListModelsV1ModelsGet200Response,
)
from ogx_client.models.open_ai_list_models_response import OpenAIListModelsResponse
from ogx_client.models.open_ai_model import OpenAIModel
from ogx_client.models.open_ai_response_object import OpenAIResponseObject
from ogx_client.models.version_info import VersionInfo
from pydantic_ai import AgentRunResultEvent
from pydantic_ai.messages import (
Expand Down Expand Up @@ -112,52 +112,99 @@ def make_openai_model(
)


def make_openai_response_object( # pylint: disable=too-many-arguments,too-many-positional-arguments
*,
response_id: str = "response-123",
content: str = "This is a test response about Ansible.",
model: str = TEST_MODEL,
tool_calls: Optional[list[dict[str, Any]]] = None,
refusal: Optional[str] = None,
input_tokens: int = 10,
output_tokens: int = 5,
) -> OpenAIResponseObject:
"""Build a real ``ogx_client`` OpenAI Responses API object for tests.

Parameters:
response_id: Response identifier returned by the mocked API.
content: Assistant message text for the default output item.
model: Model identifier on the response object.
tool_calls: Optional function-call output items to append.
refusal: Optional refusal text; when set, emits a refusal content part.
input_tokens: Input token count for usage metadata.
output_tokens: Output token count for usage metadata.

Returns:
``OpenAIResponseObject`` instance suitable for ``dump_ogx_model()``.
"""
output: list[dict[str, Any]] = list(tool_calls or [])
message_content: list[dict[str, Any]] = (
[{"type": "refusal", "refusal": refusal}]
if refusal
else [{"type": "output_text", "text": content, "annotations": []}]
)
output.append(
{
"type": "message",
"id": "msg-1",
"role": "assistant",
"status": "completed",
"content": message_content,
}
)

payload: dict[str, Any] = {
"id": response_id,
"object": "response",
"created_at": 1_700_000_000,
"status": "completed",
"model": model,
"store": False,
"output": output,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0},
},
}
response = OpenAIResponseObject.from_dict(payload)
assert response is not None
return response


def create_mock_llm_response( # pylint: disable=too-many-arguments,too-many-positional-arguments
mocker: MockerFixture,
content: str = "This is a test response about Ansible.",
tool_calls: Optional[list[Any]] = None,
tool_calls: Optional[list[dict[str, Any]]] = None,
refusal: Optional[str] = None,
input_tokens: int = 10,
output_tokens: int = 5,
) -> Any:
"""Create a customizable mock LLM response.
) -> OpenAIResponseObject:
"""Create a customizable LLM response for integration test mocks.

Helper function to create mock LLM responses with configurable content,
tool calls, refusals, and token counts. Useful for tests that need to
customize the response behavior.
Helper function to create ``ogx_client`` response objects with configurable
content, tool calls, refusals, and token counts.

Args:
mocker: pytest-mock fixture
content: Response content text
tool_calls: Optional list of tool calls
refusal: Optional refusal message (for shield violations)
input_tokens: Input token count for usage
output_tokens: Output token count for usage
mocker: pytest-mock fixture (kept for backward compatibility).
content: Response content text.
tool_calls: Optional function-call output items.
refusal: Optional refusal message (for shield violations).
input_tokens: Input token count for usage.
output_tokens: Output token count for usage.

Returns:
Mock LLM response object with the specified configuration.
``OpenAIResponseObject`` with the specified configuration.
"""
mock_response = mocker.MagicMock(spec=OpenAIResponseObject)
mock_response.id = "response-123"

# Create output message
mock_output_item = mocker.MagicMock()
mock_output_item.type = "message"
mock_output_item.role = "assistant"
mock_output_item.content = content
mock_output_item.refusal = refusal

mock_response.output = [mock_output_item]
mock_response.stop_reason = "end_turn" if not refusal else "stop"
mock_response.tool_calls = tool_calls or []

# Mock usage
mock_usage = mocker.MagicMock()
mock_usage.input_tokens = input_tokens
mock_usage.output_tokens = output_tokens
mock_response.usage = mock_usage

return mock_response
_ = mocker
return make_openai_response_object(
content=content,
tool_calls=tool_calls,
refusal=refusal,
input_tokens=input_tokens,
output_tokens=output_tokens,
)


def create_mock_vector_store_response(
Expand Down Expand Up @@ -795,25 +842,9 @@ def mock_ogx_client_fixture(
mock_client = mocker.AsyncMock()

# Mock responses.create with default assistant response
mock_response = mocker.MagicMock(spec=OpenAIResponseObject)
mock_response.id = "response-123"

mock_output_item = mocker.MagicMock()
mock_output_item.type = "message"
mock_output_item.role = "assistant"
mock_output_item.content = "This is a test response about Ansible."
mock_output_item.refusal = None

mock_response.output = [mock_output_item]
mock_response.stop_reason = "end_turn"
mock_response.tool_calls = []

mock_usage = mocker.MagicMock()
mock_usage.input_tokens = 10
mock_usage.output_tokens = 5
mock_response.usage = mock_usage

mock_client.responses.create.return_value = mock_response
mock_client.responses.create = mocker.AsyncMock(
return_value=make_openai_response_object()
)

# Mock openai.list
mock_client.openai.list.return_value = make_openai_models_list_response(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest
from fastapi import Request
from ogx_client.models.open_ai_response_object import OpenAIResponseObject
from pytest_mock import MockerFixture

import constants
Expand Down Expand Up @@ -36,6 +37,7 @@
"created_at": 1700000000,
"status": "completed",
"model": "test-provider/test-model",
"store": False,
"output": [
{
"type": "message",
Expand Down Expand Up @@ -68,8 +70,8 @@
def _build_responses_mock_client(mocker: MockerFixture) -> Any:
"""Build a mock client suitable for the /responses endpoint."""
mock_client = _build_base_mock_client(mocker)
mock_client.responses.create.return_value.model_dump.return_value = (
_RESPONSE_DUMP.copy()
mock_client.responses.create = mocker.AsyncMock(
return_value=OpenAIResponseObject.from_dict(_RESPONSE_DUMP)
)
return mock_client

Expand Down
20 changes: 5 additions & 15 deletions tests/integration/endpoints/test_responses_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import pytest
from fastapi import Request
from fastapi.responses import StreamingResponse
from ogx_client.models.open_ai_response_object import OpenAIResponseObject
from pytest_mock import MockerFixture
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -43,6 +44,7 @@
"created_at": 1700000000,
"status": "completed",
"model": "test-provider/test-model",
"store": False,
"output": [
{
"type": "message",
Expand Down Expand Up @@ -77,21 +79,9 @@ def _build_mock_client(mocker: MockerFixture) -> Any:
"""
mock_client = mocker.AsyncMock()

mock_response = mocker.MagicMock()
mock_response.id = "resp_integ_test"
mock_output = mocker.MagicMock()
mock_output.type = "message"
mock_output.role = "assistant"
mock_output.content = "Ansible is an automation tool."
mock_output.refusal = None
mock_response.output = [mock_output]
mock_response.usage = mocker.MagicMock()
mock_response.usage.input_tokens = 10
mock_response.usage.output_tokens = 5
mock_response.status = "completed"
mock_response.model = "test-provider/test-model"
mock_response.model_dump.return_value = _RESPONSE_DUMP.copy()
mock_client.responses.create = mocker.AsyncMock(return_value=mock_response)
mock_client.responses.create = mocker.AsyncMock(
return_value=OpenAIResponseObject.from_dict(_RESPONSE_DUMP)
)

mock_client.openai.list.return_value = make_openai_models_list_response(
make_openai_model()
Expand Down
Loading
Loading