diff --git a/README.md b/README.md index a838be0..e24c20c 100644 --- a/README.md +++ b/README.md @@ -414,7 +414,11 @@ response = client.responses.create( input="Write a haiku about programming", ) -print(response.output[0].content[0].text) +print(response.output_text) + +# `output` also holds reasoning and function_call items, so iterate by type +for item in response.output: + print(item.type) # With reasoning response = client.responses.create( @@ -555,6 +559,10 @@ the `publish` confirmation value. ## Changelog ### Unreleased +- Parse real Responses API output: `output_text` and `refusal` message parts, + `reasoning` and `function_call` items, and unmodelled item types (kept as + `ResponseUnknownOutputItem`). Add `Response.output_text` and the `queued` / + `in_progress` statuses. - Add `parallel_tool_calls` and `max_completion_tokens` to `chat.completions.create()`. - Mark `rerank.create()` as unavailable: it emits a `DeprecationWarning`, because the Apertis API does not serve `POST /v1/rerank`. diff --git a/apertis/types/__init__.py b/apertis/types/__init__.py index 07f9313..7c5906f 100644 --- a/apertis/types/__init__.py +++ b/apertis/types/__init__.py @@ -55,6 +55,13 @@ ResponseReasoningContent, ResponseUsage, ResponseInputItem, + ResponseOutputItem, + ResponseOutputMessage, + ResponseOutputText, + ResponseOutputRefusal, + ResponseReasoningItem, + ResponseFunctionToolCall, + ResponseUnknownOutputItem, ) from apertis.types.messages import ( Message, @@ -124,6 +131,13 @@ "ResponseReasoningContent", "ResponseUsage", "ResponseInputItem", + "ResponseOutputItem", + "ResponseOutputMessage", + "ResponseOutputText", + "ResponseOutputRefusal", + "ResponseReasoningItem", + "ResponseFunctionToolCall", + "ResponseUnknownOutputItem", # Messages API "Message", "MessageParam", diff --git a/apertis/types/responses.py b/apertis/types/responses.py index 0eee04b..65a1bfb 100644 --- a/apertis/types/responses.py +++ b/apertis/types/responses.py @@ -2,9 +2,9 @@ from __future__ import annotations -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Type, Union -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, field_validator from typing_extensions import TypedDict, Required, NotRequired @@ -13,33 +13,94 @@ # ============================================================================= +class ResponseOutputText(BaseModel): + """Text part of an output message.""" + + type: Literal["output_text"] + text: str + annotations: List[Dict[str, Any]] = [] + + +class ResponseOutputRefusal(BaseModel): + """Refusal part of an output message.""" + + type: Literal["refusal"] + refusal: str + + class ResponseTextContent(BaseModel): - """Text content in a response.""" + """Legacy text part; current responses use ResponseOutputText.""" type: Literal["text"] text: str class ResponseReasoningContent(BaseModel): - """Reasoning content in a response (for thinking models).""" + """Legacy reasoning part; current responses use ResponseReasoningItem.""" type: Literal["reasoning"] summary: Optional[List[Dict[str, Any]]] = None -ResponseContent = Union[ResponseTextContent, ResponseReasoningContent] +ResponseContent = Union[ + ResponseOutputText, ResponseOutputRefusal, ResponseTextContent, ResponseReasoningContent +] class ResponseOutput(BaseModel): - """Output item in a response.""" + """Assistant message item in a response's output.""" type: Literal["message"] id: str - status: Literal["completed", "incomplete", "cancelled"] + status: Literal["in_progress", "completed", "incomplete", "cancelled"] role: Literal["assistant"] content: List[ResponseContent] +ResponseOutputMessage = ResponseOutput + + +class ResponseReasoningItem(BaseModel): + """Reasoning item emitted by reasoning models.""" + + type: Literal["reasoning"] + id: str + summary: List[Dict[str, Any]] = [] + content: Optional[List[Dict[str, Any]]] = None + encrypted_content: Optional[str] = None + status: Optional[Literal["in_progress", "completed", "incomplete"]] = None + + +class ResponseFunctionToolCall(BaseModel): + """Function call the model asks the caller to run.""" + + type: Literal["function_call"] + call_id: str + name: str + arguments: str + id: Optional[str] = None + status: Optional[Literal["in_progress", "completed", "incomplete"]] = None + + +class ResponseUnknownOutputItem(BaseModel): + """Output item of a type this SDK does not model; all fields are kept.""" + + model_config = ConfigDict(extra="allow") + + type: str + + +ResponseOutputItem = Union[ + ResponseOutput, ResponseReasoningItem, ResponseFunctionToolCall, ResponseUnknownOutputItem +] + +_OUTPUT_ITEM_TYPES: Dict[str, Type[BaseModel]] = { + "message": ResponseOutput, + "reasoning": ResponseReasoningItem, + "function_call": ResponseFunctionToolCall, +} + + class ResponseUsage(BaseModel): """Token usage for responses.""" @@ -54,12 +115,37 @@ class Response(BaseModel): id: str object: Literal["response"] created_at: int - status: Literal["completed", "incomplete", "cancelled", "failed"] + status: Literal["queued", "in_progress", "completed", "incomplete", "cancelled", "failed"] model: str - output: List[ResponseOutput] + output: List[ResponseOutputItem] usage: Optional[ResponseUsage] = None error: Optional[Dict[str, Any]] = None + @field_validator("output", mode="before") + @classmethod + def _parse_output_items(cls, value: Any) -> Any: + # Dispatch on "type" so a malformed known item raises instead of + # degrading into an unknown one, and new item types are kept. + if not isinstance(value, list): + return value + return [ + _OUTPUT_ITEM_TYPES.get(str(item.get("type")), ResponseUnknownOutputItem).model_validate(item) + if isinstance(item, dict) + else item + for item in value + ] + + @property + def output_text(self) -> str: + """Concatenated text of every output_text part in the message items.""" + return "".join( + part.text + for item in self.output + if isinstance(item, ResponseOutput) + for part in item.content + if isinstance(part, (ResponseOutputText, ResponseTextContent)) + ) + # ============================================================================= # Request Parameter Types diff --git a/tests/test_responses.py b/tests/test_responses.py index 5d91d5c..b020612 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -5,8 +5,18 @@ import httpx import pytest import respx +from pydantic import ValidationError -from apertis import Apertis +from apertis import Apertis, AsyncApertis +from apertis.types.responses import ( + Response, + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseOutputRefusal, + ResponseOutputText, + ResponseReasoningItem, + ResponseUnknownOutputItem, +) class TestResponsesCreate: @@ -175,3 +185,113 @@ def test_create_response_with_reasoning(self, client: Apertis) -> None: assert response.status == "completed" assert len(response.output[0].content) == 2 + + +# Shapes follow the OpenAI Responses API object (openai-python 3.26 types): +# message items carry `output_text` parts, and reasoning / function_call / +# other tool items sit beside them in `output`. +_REAL_SHAPE = { + "id": "resp_1", + "object": "response", + "created_at": 1741476542, + "status": "completed", + "model": "gpt-5.5", + "output": [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{"type": "summary_text", "text": "Considered the greeting."}], + }, + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Hi", "annotations": []}, + {"type": "output_text", "text": " there.", "annotations": []}, + ], + }, + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "get_weather", + "arguments": "{\"city\": \"Paris\"}", + "status": "completed", + }, + {"type": "web_search_call", "id": "ws_1", "status": "completed"}, + ], + "usage": {"input_tokens": 12, "output_tokens": 30, "total_tokens": 42}, +} + + +class TestResponsesOutputItems: + """Real Responses API output parses into typed items.""" + + @respx.mock + def test_create_parses_real_output(self, client: Apertis) -> None: + respx.post("https://api.apertis.ai/v1/responses").mock( + return_value=httpx.Response(200, json=_REAL_SHAPE) + ) + + response = client.responses.create(model="gpt-5.5", input="Hi") + + assert response.output_text == "Hi there." + reasoning, message, call, unknown = response.output + assert isinstance(reasoning, ResponseReasoningItem) + assert reasoning.summary[0]["text"] == "Considered the greeting." + assert isinstance(message, ResponseOutputMessage) + assert isinstance(message.content[0], ResponseOutputText) + assert message.content[0].annotations == [] + assert isinstance(call, ResponseFunctionToolCall) + assert (call.call_id, call.name, call.arguments) == ( + "call_1", + "get_weather", + "{\"city\": \"Paris\"}", + ) + # Item types this SDK does not model are kept, not rejected. + assert isinstance(unknown, ResponseUnknownOutputItem) + assert unknown.type == "web_search_call" + assert unknown.model_dump()["status"] == "completed" + + @respx.mock + async def test_async_create_parses_real_output(self, async_client: AsyncApertis) -> None: + respx.post("https://api.apertis.ai/v1/responses").mock( + return_value=httpx.Response(200, json=_REAL_SHAPE) + ) + + response = await async_client.responses.create(model="gpt-5.5", input="Hi") + + assert response.output_text == "Hi there." + + def test_in_progress_and_refusal(self) -> None: + response = Response.model_validate( + { + **_REAL_SHAPE, + "status": "in_progress", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "in_progress", + "role": "assistant", + "content": [{"type": "refusal", "refusal": "I can't help with that."}], + } + ], + } + ) + + assert response.status == "in_progress" + assert isinstance(response.output[0].content[0], ResponseOutputRefusal) + assert response.output_text == "" + + def test_message_with_bad_content_still_fails(self) -> None: + """A known item type with a malformed body is an error, not an unknown item.""" + with pytest.raises(ValidationError): + Response.model_validate( + { + **_REAL_SHAPE, + "output": [{"type": "message", "id": "m", "role": "assistant"}], + } + )