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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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`.
Expand Down
14 changes: 14 additions & 0 deletions apertis/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
ResponseReasoningContent,
ResponseUsage,
ResponseInputItem,
ResponseOutputItem,
ResponseOutputMessage,
ResponseOutputText,
ResponseOutputRefusal,
ResponseReasoningItem,
ResponseFunctionToolCall,
ResponseUnknownOutputItem,
)
from apertis.types.messages import (
Message,
Expand Down Expand Up @@ -124,6 +131,13 @@
"ResponseReasoningContent",
"ResponseUsage",
"ResponseInputItem",
"ResponseOutputItem",
"ResponseOutputMessage",
"ResponseOutputText",
"ResponseOutputRefusal",
"ResponseReasoningItem",
"ResponseFunctionToolCall",
"ResponseUnknownOutputItem",
# Messages API
"Message",
"MessageParam",
Expand Down
104 changes: 95 additions & 9 deletions apertis/types/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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."""

Expand All @@ -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
Expand Down
122 changes: 121 additions & 1 deletion tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"}],
}
)
Loading