mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
Merge 7f52f18118 into 64d8d7f8cb
This commit is contained in:
commit
32878450c7
8 changed files with 166 additions and 70 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue