This commit is contained in:
Mateo Wang 2026-06-27 12:00:50 -07:00 • committed by GitHub
commit 32878450c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 166 additions and 70 deletions

View file

@ -219,8 +219,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
"status": "in_progress",
}
),
sequence_number=self._sequence_number,
)
event.__dict__["sequence_number"] = self._sequence_number
self._pending_tool_events.append(event)
if fn_args_delta:
@ -232,16 +232,19 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
for i in range(0, len(fn_args_delta), chunk_size):
delta_chunk = fn_args_delta[i : i + chunk_size]
self._sequence_number += 1
# sequence_number is passed via constructor so it lands in
# __pydantic_extra__ (BaseLiteLLMOpenAIResponseObject allows extra
# fields). Setting it via __dict__ afterward is silently dropped
# by model_dump() and breaks strict OpenAI Responses-API clients.
delta_event: BaseLiteLLMOpenAIResponseObject = (
FunctionCallArgumentsDeltaEvent(
type=ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA,
item_id=call_id,
output_index=output_index,
delta=delta_chunk,
sequence_number=self._sequence_number,
)
)
# Add sequence_number as extra field (BaseLiteLLMOpenAIResponseObject allows extra fields)
delta_event.__dict__["sequence_number"] = self._sequence_number
self._pending_tool_events.append(delta_event)
def _queue_final_tool_call_done_events(
@ -306,8 +309,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
"status": "in_progress",
}
),
sequence_number=self._sequence_number,
)
event.__dict__["sequence_number"] = self._sequence_number
self._pending_tool_events.append(event)
final_args = fn_args or self._tool_args_by_call_id.get(call_id, "")
@ -328,8 +331,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
item_id=call_id,
output_index=output_index,
delta=delta_chunk,
sequence_number=self._sequence_number,
)
delta_event.__dict__["sequence_number"] = self._sequence_number
self._pending_tool_events.append(delta_event)
self._sequence_number += 1
@ -338,8 +341,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
item_id=call_id,
output_index=output_index,
arguments=final_args,
sequence_number=self._sequence_number,
)
done_event.__dict__["sequence_number"] = self._sequence_number
self._pending_tool_events.append(done_event)
self._sequence_number += 1
@ -424,30 +427,28 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
"""
response_created_event_data = self._default_response_created_event_data()
self._sequence_number += 1
event = ResponseCreatedEvent(
return ResponseCreatedEvent(
type=ResponsesAPIStreamEvents.RESPONSE_CREATED,
response=ResponsesAPIResponse(**response_created_event_data),
sequence_number=self._sequence_number,
)
event.__dict__["sequence_number"] = self._sequence_number
return event
def create_response_in_progress_event(self) -> ResponseInProgressEvent:
response_in_progress_event_data = self._default_response_created_event_data()
response_in_progress_event_data["status"] = "in_progress"
self._sequence_number += 1
event = ResponseInProgressEvent(
return ResponseInProgressEvent(
type=ResponsesAPIStreamEvents.RESPONSE_IN_PROGRESS,
response=ResponsesAPIResponse(**response_in_progress_event_data),
sequence_number=self._sequence_number,
)
event.__dict__["sequence_number"] = self._sequence_number
return event
def create_output_item_added_event(self) -> OutputItemAddedEvent:
if self._cached_item_id is None:
self._cached_item_id = f"msg_{str(uuid.uuid4())}"
self._sequence_number += 1
event = OutputItemAddedEvent(
return OutputItemAddedEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
output_index=0,
item=BaseLiteLLMOpenAIResponseObject(
@ -459,16 +460,15 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
"content": [],
}
),
sequence_number=self._sequence_number,
)
event.__dict__["sequence_number"] = self._sequence_number
return event
def create_content_part_added_event(self) -> ContentPartAddedEvent:
if self._cached_item_id is None:
self._cached_item_id = f"msg_{str(uuid.uuid4())}"
self._sequence_number += 1
event = ContentPartAddedEvent(
return ContentPartAddedEvent(
type=ResponsesAPIStreamEvents.CONTENT_PART_ADDED,
item_id=self._cached_item_id,
output_index=0,
@ -476,9 +476,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
part=BaseLiteLLMOpenAIResponseObject(
**{"type": "output_text", "text": "", "annotations": []}
),
sequence_number=self._sequence_number,
)
event.__dict__["sequence_number"] = self._sequence_number
return event
def _merge_provider_specific_fields(self, src: dict) -> None:
"""Merge provider_specific_fields using last-value-wins for lists.
@ -599,6 +598,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
if self._cached_item_id is None:
self._cached_item_id = f"msg_{str(uuid.uuid4())}"
self._sequence_number += 1
return OutputTextDoneEvent(
type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DONE,
item_id=self._cached_item_id,
@ -606,6 +606,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
content_index=0,
text=getattr(litellm_complete_object.choices[0].message, "content", "") # type: ignore
or "",
sequence_number=self._sequence_number,
)
def create_output_content_part_done_event(
@ -641,12 +642,14 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
logprobs=None,
)
self._sequence_number += 1
return ContentPartDoneEvent(
type=ResponsesAPIStreamEvents.CONTENT_PART_DONE,
item_id=self._cached_item_id,
output_index=0,
content_index=0,
part=part,
sequence_number=self._sequence_number,
)
def create_output_item_done_event(
@ -663,10 +666,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
response_annotations = LiteLLMCompletionResponsesConfig._transform_chat_completion_annotations_to_response_output_annotations(
annotations=annotations
)
self._sequence_number += 1
return OutputItemDoneEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE,
output_index=0,
sequence_number=1,
sequence_number=self._sequence_number,
item=BaseLiteLLMOpenAIResponseObject(
**{
"id": self._cached_item_id,
@ -824,8 +828,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
"summary": None,
}
),
sequence_number=self._sequence_number,
)
event.__dict__["sequence_number"] = self._sequence_number
self._pending_response_events.append(event)
return
@ -849,8 +853,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
"content": [],
}
),
sequence_number=self._sequence_number,
)
event.__dict__["sequence_number"] = self._sequence_number
self._pending_response_events.append(event)
# Emit content_part.added immediately after output_item.added for message
@ -1082,6 +1086,9 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
if hasattr(annotation, "model_dump")
else dict(annotation)
)
# Sequence number is assigned at emit time (see Priority 4
# below) to preserve monotonic ordering relative to
# higher-priority events from later chunks.
event = OutputTextAnnotationAddedEvent(
type=ResponsesAPIStreamEvents.OUTPUT_TEXT_ANNOTATION_ADDED,
item_id=item_id,
@ -1099,11 +1106,13 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
):
reasoning_content = chunk.choices[0].delta.reasoning_content
self._sequence_number += 1
return ReasoningSummaryTextDeltaEvent(
type=ResponsesAPIStreamEvents.REASONING_SUMMARY_TEXT_DELTA,
item_id=f"rs_{hash(str(reasoning_content))}",
output_index=0,
delta=reasoning_content,
sequence_number=self._sequence_number,
)
# Priority 2: Handle text deltas
@ -1116,8 +1125,8 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
output_index=0,
content_index=0,
delta=delta_content,
sequence_number=self._sequence_number,
)
text_delta_event.__dict__["sequence_number"] = self._sequence_number
return text_delta_event
# Priority 3: Handle tool call deltas (if any) -> queue events and emit them
@ -1133,12 +1142,16 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
return self._pending_tool_events.pop(0)
# Priority 4: If we have pending annotation events, emit the next one
# This happens when the current chunk has no text/reasoning content
# This happens when the current chunk has no text/reasoning content.
# Assign the sequence number here (at emit time) so it stays monotonic
# relative to other events emitted from intervening chunks.
if (
hasattr(self, "_pending_annotation_events")
and self._pending_annotation_events
):
event = self._pending_annotation_events.pop(0)
self._sequence_number += 1
event.sequence_number = self._sequence_number
return event
# Priority 5: If we have pending tool events (from earlier chunk), emit the next one
@ -1198,9 +1211,11 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
litellm_metadata=self.litellm_metadata,
)
self._sequence_number += 1
return ResponseCompletedEvent(
type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
response=encoded_response,
sequence_number=self._sequence_number,
)
else:
return None

View file

@ -1718,7 +1718,10 @@ class LiteLLMCompletionResponsesConfig:
status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status(
finish_reason
),
text={},
# OpenAI Responses spec requires `text.format` to be present. Honor the
# request's `text` config when supplied; otherwise default to plain text.
# Strict deserializers (Grok Build CLI, OpenAI SDK) reject `text: {}`.
text=responses_api_request.get("text") or {"format": {"type": "text"}},
truncation=getattr(chat_completion_response, "truncation", None),
usage=LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(
chat_completion_response=chat_completion_response
@ -2105,6 +2108,8 @@ class LiteLLMCompletionResponsesConfig:
input_tokens=0,
output_tokens=0,
total_tokens=0,
input_tokens_details=InputTokensDetails(cached_tokens=0),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
)
response_usage = ResponseAPIUsage(
@ -2117,21 +2122,18 @@ class LiteLLMCompletionResponsesConfig:
if hasattr(usage, "cost") and usage.cost is not None:
setattr(response_usage, "cost", usage.cost)
# Translate prompt_tokens_details to input_tokens_details
if (
hasattr(usage, "prompt_tokens_details")
and usage.prompt_tokens_details is not None
):
prompt_details = usage.prompt_tokens_details
input_details_dict: Dict[str, int] = {}
# Translate prompt_tokens_details -> input_tokens_details.
# OpenAI's Responses spec requires this field to always be present on
# response.completed.usage, so we emit it with defaults if upstream
# omitted prompt_tokens_details entirely.
prompt_details = getattr(usage, "prompt_tokens_details", None)
input_details_dict: Dict[str, int] = {"cached_tokens": 0}
if prompt_details is not None:
if (
hasattr(prompt_details, "cached_tokens")
and prompt_details.cached_tokens is not None
):
input_details_dict["cached_tokens"] = prompt_details.cached_tokens
else:
input_details_dict["cached_tokens"] = 0
if (
hasattr(prompt_details, "text_tokens")
@ -2145,18 +2147,14 @@ class LiteLLMCompletionResponsesConfig:
):
input_details_dict["audio_tokens"] = prompt_details.audio_tokens
if input_details_dict:
response_usage.input_tokens_details = InputTokensDetails(
**input_details_dict
)
response_usage.input_tokens_details = InputTokensDetails(**input_details_dict)
# Translate completion_tokens_details to output_tokens_details
if (
hasattr(usage, "completion_tokens_details")
and usage.completion_tokens_details is not None
):
completion_details = usage.completion_tokens_details
output_details_dict: Dict[str, int] = {}
# Translate completion_tokens_details -> output_tokens_details. Same
# always-present requirement; strict deserializers (Grok Build CLI, the
# OpenAI SDK) reject a usage object without it.
completion_details = getattr(usage, "completion_tokens_details", None)
output_details_dict: Dict[str, int] = {"reasoning_tokens": 0}
if completion_details is not None:
if (
hasattr(completion_details, "reasoning_tokens")
and completion_details.reasoning_tokens is not None
@ -2164,8 +2162,6 @@ class LiteLLMCompletionResponsesConfig:
output_details_dict["reasoning_tokens"] = (
completion_details.reasoning_tokens
)
else:
output_details_dict["reasoning_tokens"] = 0
if (
hasattr(completion_details, "text_tokens")
@ -2179,10 +2175,9 @@ class LiteLLMCompletionResponsesConfig:
):
output_details_dict["image_tokens"] = completion_details.image_tokens
if output_details_dict:
response_usage.output_tokens_details = OutputTokensDetails(
**output_details_dict
)
response_usage.output_tokens_details = OutputTokensDetails(
**output_details_dict
)
return response_usage

View file

@ -218,6 +218,7 @@ def create_mcp_call_events(
output_item_done_event = OutputItemDoneEvent(
type=ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE,
output_index=0,
sequence_number=sequence_start + 4,
item=BaseLiteLLMOpenAIResponseObject(
**{
"id": item_id,

View file

@ -1213,6 +1213,18 @@ def _build_synthetic_response_events(
response=transformed,
)
)
# Assign monotonic sequence_number to every event. The helpers above
# build events without consistently passing sequence_number, and several
# event types now declare `sequence_number: int = 0` (default), which
# would otherwise serialize as 0 for most events and break the strict
# monotonic ordering guarantee expected by some Responses API clients.
for idx, event in enumerate(events, start=1):
try:
event.sequence_number = idx
except (AttributeError, ValueError):
pass
return events

View file

@ -1467,16 +1467,19 @@ class ResponsesAPIStreamEvents(str, Enum):
class ResponseCreatedEvent(BaseLiteLLMOpenAIResponseObject):
type: Literal[ResponsesAPIStreamEvents.RESPONSE_CREATED]
response: ResponsesAPIResponse
sequence_number: int = 0
class ResponseInProgressEvent(BaseLiteLLMOpenAIResponseObject):
type: Literal[ResponsesAPIStreamEvents.RESPONSE_IN_PROGRESS]
response: ResponsesAPIResponse
sequence_number: int = 0
class ResponseCompletedEvent(BaseLiteLLMOpenAIResponseObject):
type: Literal[ResponsesAPIStreamEvents.RESPONSE_COMPLETED]
response: ResponsesAPIResponse
sequence_number: int = 0
_hidden_params: dict = PrivateAttr(default_factory=dict)
@ -1503,6 +1506,7 @@ class ReasoningSummaryTextDeltaEvent(BaseLiteLLMOpenAIResponseObject):
output_index: int
summary_index: int = 0
delta: str
sequence_number: int = 0
class ReasoningSummaryTextDoneEvent(BaseLiteLLMOpenAIResponseObject):
@ -1527,12 +1531,13 @@ class OutputItemAddedEvent(BaseLiteLLMOpenAIResponseObject):
type: Literal[ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED]
output_index: int
item: Optional[BaseLiteLLMOpenAIResponseObject]
sequence_number: int = 0
class OutputItemDoneEvent(BaseLiteLLMOpenAIResponseObject):
type: Literal[ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE]
output_index: int
sequence_number: int = 1
sequence_number: int = 0
item: BaseLiteLLMOpenAIResponseObject
@ -1555,6 +1560,7 @@ class ContentPartAddedEvent(BaseLiteLLMOpenAIResponseObject):
output_index: int
content_index: int
part: BaseLiteLLMOpenAIResponseObject
sequence_number: int = 0
class ContentPartDonePartOutputText(BaseLiteLLMOpenAIResponseObject):
@ -1587,6 +1593,7 @@ class ContentPartDoneEvent(BaseLiteLLMOpenAIResponseObject):
output_index: int
content_index: int
part: PART_UNION_TYPES
sequence_number: int = 0
class OutputTextDeltaEvent(BaseLiteLLMOpenAIResponseObject):
@ -1595,6 +1602,7 @@ class OutputTextDeltaEvent(BaseLiteLLMOpenAIResponseObject):
output_index: int
content_index: int
delta: str
sequence_number: int = 0
class OutputTextAnnotationAddedEvent(BaseLiteLLMOpenAIResponseObject):
@ -1604,6 +1612,7 @@ class OutputTextAnnotationAddedEvent(BaseLiteLLMOpenAIResponseObject):
content_index: int
annotation_index: int
annotation: dict
sequence_number: int = 0
class OutputTextDoneEvent(BaseLiteLLMOpenAIResponseObject):
@ -1612,6 +1621,7 @@ class OutputTextDoneEvent(BaseLiteLLMOpenAIResponseObject):
output_index: int
content_index: int
text: str
sequence_number: int = 0
class RefusalDeltaEvent(BaseLiteLLMOpenAIResponseObject):
@ -1635,6 +1645,7 @@ class FunctionCallArgumentsDeltaEvent(BaseLiteLLMOpenAIResponseObject):
item_id: str
output_index: int
delta: str
sequence_number: int = 0
class FunctionCallArgumentsDoneEvent(BaseLiteLLMOpenAIResponseObject):
@ -1642,6 +1653,7 @@ class FunctionCallArgumentsDoneEvent(BaseLiteLLMOpenAIResponseObject):
item_id: str
output_index: int
arguments: str
sequence_number: int = 0
class FileSearchCallInProgressEvent(BaseLiteLLMOpenAIResponseObject):

View file

@ -1623,7 +1623,13 @@ class TestUsageTransformation:
assert response_usage.input_tokens_details.text_tokens == 9
def test_transform_usage_without_details(self):
"""Test transformation when prompt_tokens_details and completion_tokens_details are None"""
"""Test transformation when prompt_tokens_details and completion_tokens_details are None.
Per the OpenAI Responses spec, `input_tokens_details` and
`output_tokens_details` must always be present on the response.completed
usage object; strict deserializers (Grok Build CLI, OpenAI SDK) reject
the event when either is missing. We emit them with zero defaults.
"""
# Setup: Usage without details (basic usage only)
usage = Usage(
prompt_tokens=9,
@ -1651,12 +1657,14 @@ class TestUsageTransformation:
chat_completion_response=chat_completion_response
)
# Assert: Basic usage should still be transformed, but details should be None
# Assert: basic counts transformed, and details always present with zero defaults
assert response_usage.input_tokens == 9
assert response_usage.output_tokens == 27
assert response_usage.total_tokens == 36
assert response_usage.input_tokens_details is None
assert response_usage.output_tokens_details is None
assert response_usage.input_tokens_details is not None
assert response_usage.input_tokens_details.cached_tokens == 0
assert response_usage.output_tokens_details is not None
assert response_usage.output_tokens_details.reasoning_tokens == 0
def test_transform_usage_with_image_tokens(self):
"""Test that image_tokens from Vertex AI/Gemini are properly transformed to output_tokens_details"""

View file

@ -187,11 +187,15 @@ def test_tool_call_arguments_are_chunked_to_match_openai_behavior():
# Process the chunk once - it queues all events internally
evt = iterator._transform_chat_completion_chunk_to_response_api_chunk(chunk)
# First event should be OUTPUT_ITEM_ADDED
# First event should be OUTPUT_ITEM_ADDED, carrying a serialized sequence_number.
# `sequence_number` is passed through the constructor so it lands in
# __pydantic_extra__ (BaseLiteLLMOpenAIResponseObject allows extras); a raw
# __dict__ assignment would be silently dropped by model_dump(). We assert on
# the dump because that is what hits the wire as SSE.
assert evt is not None
assert evt.type == ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED
assert evt.output_index == 1
assert hasattr(evt, "__dict__") and "sequence_number" in evt.__dict__
assert "sequence_number" in evt.model_dump()
# Collect all remaining delta events from the pending queue by creating empty chunks
delta_events = []
@ -220,19 +224,21 @@ def test_tool_call_arguments_are_chunked_to_match_openai_behavior():
# Verify multiple delta events were created (at least 6 chunks for 67 chars)
assert len(delta_events) >= 6 # 67 chars split into chunks of max 10 chars each
# Verify each delta is at most 10 characters
# Verify each delta is at most 10 characters and carries a serializable
# sequence_number (the value lives in __pydantic_extra__, not __dict__).
for evt in delta_events:
assert len(evt.delta) <= 10
assert evt.item_id == "call_test"
assert evt.output_index == 1
assert hasattr(evt, "__dict__") and "sequence_number" in evt.__dict__
assert "sequence_number" in evt.model_dump()
# Verify all deltas concatenated equal the original arguments
concatenated = "".join(evt.delta for evt in delta_events)
assert concatenated == large_arguments
# Verify sequence numbers are increasing
sequence_numbers = [evt.__dict__["sequence_number"] for evt in delta_events]
# Verify sequence numbers are increasing and unique (read from the dumped
# payload, which is what actually hits the wire).
sequence_numbers = [evt.model_dump()["sequence_number"] for evt in delta_events]
assert sequence_numbers == sorted(sequence_numbers)
assert len(set(sequence_numbers)) == len(sequence_numbers) # All unique
@ -397,3 +403,40 @@ def test_reused_index_with_new_call_id_marks_fallback_ambiguous():
assert arguments_by_call_id["call_b"] == '{"b":'
assert arguments_by_call_id["call_a"] != '{"a":1}'
assert arguments_by_call_id["call_b"] != '{"b":1}'
def test_streaming_events_serialize_sequence_number_for_strict_clients():
"""Pin the Grok Build CLI / OpenAI SDK contract: every emitted streaming
event must carry `sequence_number` in its on-wire JSON payload.
Earlier code set `event.__dict__["sequence_number"] = N` after construction,
which silently dropped the field during Pydantic's `model_dump()` and broke
strict deserializers. The fix routes the value through the constructor so it
lands in `__pydantic_extra__` and survives serialization.
"""
iterator = LiteLLMCompletionStreamingIterator(
model="test-model",
litellm_custom_stream_wrapper=AsyncMock(),
request_input="hi",
responses_api_request={},
)
created = iterator.create_response_created_event()
in_progress = iterator.create_response_in_progress_event()
item_added = iterator.create_output_item_added_event()
part_added = iterator.create_content_part_added_event()
for evt in (created, in_progress, item_added, part_added):
dumped = evt.model_dump()
assert (
"sequence_number" in dumped
), f"{type(evt).__name__} dropped sequence_number"
assert isinstance(dumped["sequence_number"], int)
# And the values must be monotonic — strict clients enforce ordering.
seqs = [
evt.model_dump()["sequence_number"]
for evt in (created, in_progress, item_added, part_added)
]
assert seqs == sorted(seqs), seqs
assert len(set(seqs)) == len(seqs), seqs

View file

@ -131,7 +131,11 @@ def test_transform_usage_no_token_details():
"""
Test that transformation works when completion response has NO token details.
This simulates providers that don't return detailed token breakdowns.
This simulates providers that don't return detailed token breakdowns. Per the
OpenAI Responses spec, `input_tokens_details` and `output_tokens_details` must
always be present on `response.completed.usage`; strict deserializers (Grok
Build CLI, OpenAI SDK) reject the event when either is missing. Defaults are
zeros.
"""
completion_response = create_mock_completion_response(
model="gpt-4",
@ -150,9 +154,11 @@ def test_transform_usage_no_token_details():
assert responses_usage.output_tokens == 20
assert responses_usage.total_tokens == 30
# Token details should not be present when not provided
assert responses_usage.input_tokens_details is None
assert responses_usage.output_tokens_details is None
# Token details are always emitted, defaulting to zeros when upstream omits them.
assert isinstance(responses_usage.input_tokens_details, InputTokensDetails)
assert responses_usage.input_tokens_details.cached_tokens == 0
assert isinstance(responses_usage.output_tokens_details, OutputTokensDetails)
assert responses_usage.output_tokens_details.reasoning_tokens == 0
print("✓ Transformation works with no token details")
@ -186,8 +192,10 @@ def test_transform_usage_with_cached_tokens_only():
assert isinstance(responses_usage.input_tokens_details, InputTokensDetails)
assert responses_usage.input_tokens_details.cached_tokens == 80
# Output details should not be present (no reasoning_tokens provided)
assert responses_usage.output_tokens_details is None
# Output details are always emitted (defaulting reasoning_tokens to 0) to match
# the OpenAI Responses spec — strict deserializers require the field.
assert isinstance(responses_usage.output_tokens_details, OutputTokensDetails)
assert responses_usage.output_tokens_details.reasoning_tokens == 0
print("✓ Transformation works with cached_tokens only")
@ -216,8 +224,10 @@ def test_transform_usage_with_reasoning_tokens_only():
assert responses_usage.output_tokens == 100
assert responses_usage.total_tokens == 150
# Input details should not be present (no cached_tokens provided)
assert responses_usage.input_tokens_details is None
# Input details are always emitted (defaulting cached_tokens to 0) to match
# the OpenAI Responses spec.
assert isinstance(responses_usage.input_tokens_details, InputTokensDetails)
assert responses_usage.input_tokens_details.cached_tokens == 0
# Output details should be present with reasoning_tokens
assert responses_usage.output_tokens_details is not None