mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-02 12:21:10 +00:00
fix(responses-api): return finish_reason='tool_calls' when response.completed contains function_call items (#19745)
When using the Responses API (e.g., Azure gpt-5.1-codex-mini), the response.completed event was always returning finish_reason='stop', even when the response contained function_call items in its output. This caused agents like OpenCode to incorrectly conclude the stream ended without tools to execute, breaking tool/function calling workflows. The fix inspects the response.output field in the response.completed event to determine the correct finish_reason: - 'tool_calls' when output contains function_call items - 'stop' otherwise (text-only responses) Added tests to verify: - response.completed with function_call output returns finish_reason='tool_calls' - response.completed with message-only output returns finish_reason='stop' - response.completed with empty output returns finish_reason='stop' (backward compat) Co-authored-by: Krish Dholakia <krrishdholakia@gmail.com>
This commit is contained in:
co-authored by
Krish Dholakia
parent
d448682291
commit
504c70f4e0
@@ -62,9 +62,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def _handle_raw_dict_response_item(
|
||||
self, item: Dict[str, Any], index: int
|
||||
) -> Tuple[Optional[Any], int]:
|
||||
def _handle_raw_dict_response_item(self, item: Dict[str, Any], index: int) -> Tuple[Optional[Any], int]:
|
||||
"""
|
||||
Handle raw dict response items from Responses API (e.g., GPT-5 Codex format).
|
||||
|
||||
@@ -107,13 +105,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
if item_type == "function_call":
|
||||
# Extract provider_specific_fields if present and pass through as-is
|
||||
provider_specific_fields = item.get("provider_specific_fields")
|
||||
if provider_specific_fields and not isinstance(
|
||||
provider_specific_fields, dict
|
||||
):
|
||||
if provider_specific_fields and not isinstance(provider_specific_fields, dict):
|
||||
provider_specific_fields = (
|
||||
dict(provider_specific_fields)
|
||||
if hasattr(provider_specific_fields, "__dict__")
|
||||
else {}
|
||||
dict(provider_specific_fields) if hasattr(provider_specific_fields, "__dict__") else {}
|
||||
)
|
||||
|
||||
tool_call_dict = {
|
||||
@@ -129,9 +123,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
if provider_specific_fields:
|
||||
tool_call_dict["provider_specific_fields"] = provider_specific_fields
|
||||
# Also add to function's provider_specific_fields for consistency
|
||||
tool_call_dict["function"][
|
||||
"provider_specific_fields"
|
||||
] = provider_specific_fields
|
||||
tool_call_dict["function"]["provider_specific_fields"] = provider_specific_fields
|
||||
|
||||
msg = Message(
|
||||
content=None,
|
||||
@@ -169,7 +161,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": self._convert_content_to_responses_format(
|
||||
content, role # type: ignore
|
||||
content,
|
||||
role, # type: ignore
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -186,7 +179,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
elif isinstance(content, list):
|
||||
# Transform list content to Responses API format
|
||||
tool_output = self._convert_content_to_responses_format(
|
||||
content, "user" # Use "user" role to get input_* types
|
||||
content,
|
||||
"user", # Use "user" role to get input_* types
|
||||
)
|
||||
else:
|
||||
# Fallback: convert unexpected types to input_text
|
||||
@@ -219,9 +213,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
{
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": self._convert_content_to_responses_format(
|
||||
content, cast(str, role)
|
||||
),
|
||||
"content": self._convert_content_to_responses_format(content, cast(str, role)),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -344,9 +336,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
previous_response_id = optional_params.get("previous_response_id")
|
||||
if previous_response_id:
|
||||
# Use the existing session handler for responses API
|
||||
verbose_logger.debug(
|
||||
f"Chat provider: Warning ignoring previous response ID: {previous_response_id}"
|
||||
)
|
||||
verbose_logger.debug(f"Chat provider: Warning ignoring previous response ID: {previous_response_id}")
|
||||
|
||||
# Convert back to responses API format for the actual request
|
||||
|
||||
@@ -368,9 +358,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
"client": client,
|
||||
}
|
||||
|
||||
verbose_logger.debug(
|
||||
f"Chat provider: Final request model={api_model}, input_items={len(input_items)}"
|
||||
)
|
||||
verbose_logger.debug(f"Chat provider: Final request model={api_model}, input_items={len(input_items)}")
|
||||
|
||||
self._merge_responses_api_request_into_request_data(
|
||||
request_data, responses_api_request, instructions
|
||||
@@ -450,9 +438,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
|
||||
tool_call_dict = LiteLLMCompletionResponsesConfig.convert_response_function_tool_call_to_chat_completion_tool_call(
|
||||
tool_call_item=item,
|
||||
index=tool_call_index,
|
||||
tool_call_dict = (
|
||||
LiteLLMCompletionResponsesConfig.convert_response_function_tool_call_to_chat_completion_tool_call(
|
||||
tool_call_item=item,
|
||||
index=tool_call_index,
|
||||
)
|
||||
)
|
||||
accumulated_tool_calls.append(tool_call_dict)
|
||||
tool_call_index += 1
|
||||
@@ -472,9 +462,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
tool_calls=accumulated_tool_calls,
|
||||
reasoning_content=reasoning_content,
|
||||
)
|
||||
choices.append(
|
||||
Choices(message=msg, finish_reason="tool_calls", index=index)
|
||||
)
|
||||
choices.append(Choices(message=msg, finish_reason="tool_calls", index=index))
|
||||
reasoning_content = None
|
||||
|
||||
return choices
|
||||
@@ -510,17 +498,10 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
)
|
||||
|
||||
if len(choices) == 0:
|
||||
if (
|
||||
raw_response.incomplete_details is not None
|
||||
and raw_response.incomplete_details.reason is not None
|
||||
):
|
||||
raise ValueError(
|
||||
f"{model} unable to complete request: {raw_response.incomplete_details.reason}"
|
||||
)
|
||||
if raw_response.incomplete_details is not None and raw_response.incomplete_details.reason is not None:
|
||||
raise ValueError(f"{model} unable to complete request: {raw_response.incomplete_details.reason}")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown items in responses API response: {raw_response.output}"
|
||||
)
|
||||
raise ValueError(f"Unknown items in responses API response: {raw_response.output}")
|
||||
|
||||
setattr(model_response, "choices", choices)
|
||||
|
||||
@@ -529,11 +510,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
setattr(
|
||||
model_response,
|
||||
"usage",
|
||||
ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(
|
||||
raw_response.usage
|
||||
),
|
||||
ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(raw_response.usage),
|
||||
)
|
||||
|
||||
|
||||
# Preserve hidden params from the ResponsesAPIResponse, especially the headers
|
||||
# which contain important provider information like x-request-id
|
||||
raw_response_hidden_params = getattr(raw_response, "_hidden_params", {})
|
||||
@@ -550,24 +529,18 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
model_response._hidden_params[key] = merged_headers
|
||||
else:
|
||||
model_response._hidden_params[key] = value
|
||||
|
||||
|
||||
return model_response
|
||||
|
||||
def get_model_response_iterator(
|
||||
self,
|
||||
streaming_response: Union[
|
||||
Iterator[str], AsyncIterator[str], "ModelResponse", "BaseModel"
|
||||
],
|
||||
streaming_response: Union[Iterator[str], AsyncIterator[str], "ModelResponse", "BaseModel"],
|
||||
sync_stream: bool,
|
||||
json_mode: Optional[bool] = False,
|
||||
) -> BaseModelResponseIterator:
|
||||
return OpenAiResponsesToChatCompletionStreamIterator(
|
||||
streaming_response, sync_stream, json_mode
|
||||
)
|
||||
return OpenAiResponsesToChatCompletionStreamIterator(streaming_response, sync_stream, json_mode)
|
||||
|
||||
def _convert_content_str_to_input_text(
|
||||
self, content: str, role: str
|
||||
) -> Dict[str, Any]:
|
||||
def _convert_content_str_to_input_text(self, content: str, role: str) -> Dict[str, Any]:
|
||||
if role == "user" or role == "system" or role == "tool":
|
||||
return {"type": "input_text", "text": content}
|
||||
else:
|
||||
@@ -594,9 +567,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
if actual_image_url is None:
|
||||
raise ValueError(f"Invalid image URL: {content_image_url}")
|
||||
|
||||
image_param = ResponseInputImageParam(
|
||||
image_url=actual_image_url, detail="auto", type="input_image"
|
||||
)
|
||||
image_param = ResponseInputImageParam(image_url=actual_image_url, detail="auto", type="input_image")
|
||||
|
||||
if detail:
|
||||
image_param["detail"] = detail
|
||||
@@ -607,18 +578,14 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
self,
|
||||
content: Union[
|
||||
str,
|
||||
Iterable[
|
||||
Union["OpenAIMessageContentListBlock", "ChatCompletionThinkingBlock"]
|
||||
],
|
||||
Iterable[Union["OpenAIMessageContentListBlock", "ChatCompletionThinkingBlock"]],
|
||||
],
|
||||
role: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Convert chat completion content to responses API format"""
|
||||
from litellm.types.llms.openai import ChatCompletionImageObject
|
||||
|
||||
verbose_logger.debug(
|
||||
f"Chat provider: Converting content to responses format - input type: {type(content)}"
|
||||
)
|
||||
verbose_logger.debug(f"Chat provider: Converting content to responses format - input type: {type(content)}")
|
||||
|
||||
if isinstance(content, str):
|
||||
result = [self._convert_content_str_to_input_text(content, role)]
|
||||
@@ -627,9 +594,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
elif isinstance(content, list):
|
||||
result = []
|
||||
for i, item in enumerate(content):
|
||||
verbose_logger.debug(
|
||||
f"Chat provider: Processing content item {i}: {type(item)} = {item}"
|
||||
)
|
||||
verbose_logger.debug(f"Chat provider: Processing content item {i}: {type(item)} = {item}")
|
||||
if isinstance(item, str):
|
||||
converted = self._convert_content_str_to_input_text(item, role)
|
||||
result.append(converted)
|
||||
@@ -638,9 +603,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
# Handle multimodal content
|
||||
original_type = item.get("type")
|
||||
if original_type == "text":
|
||||
converted = self._convert_content_str_to_input_text(
|
||||
item.get("text", ""), role
|
||||
)
|
||||
converted = self._convert_content_str_to_input_text(item.get("text", ""), role)
|
||||
result.append(converted)
|
||||
verbose_logger.debug(f"Chat provider: text -> {converted}")
|
||||
elif original_type == "image_url":
|
||||
@@ -652,18 +615,14 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
),
|
||||
)
|
||||
result.append(converted)
|
||||
verbose_logger.debug(
|
||||
f"Chat provider: image_url -> {converted}"
|
||||
)
|
||||
verbose_logger.debug(f"Chat provider: image_url -> {converted}")
|
||||
else:
|
||||
# Try to map other types to responses API format
|
||||
item_type = original_type or "input_text"
|
||||
if item_type == "image":
|
||||
converted = {"type": "input_image", **item}
|
||||
result.append(converted)
|
||||
verbose_logger.debug(
|
||||
f"Chat provider: image -> {converted}"
|
||||
)
|
||||
verbose_logger.debug(f"Chat provider: image -> {converted}")
|
||||
elif item_type in [
|
||||
"input_text",
|
||||
"input_image",
|
||||
@@ -675,18 +634,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
]:
|
||||
# Already in responses API format
|
||||
result.append(item)
|
||||
verbose_logger.debug(
|
||||
f"Chat provider: passthrough -> {item}"
|
||||
)
|
||||
verbose_logger.debug(f"Chat provider: passthrough -> {item}")
|
||||
else:
|
||||
# Default to input_text for unknown types
|
||||
converted = self._convert_content_str_to_input_text(
|
||||
str(item.get("text", item)), role
|
||||
)
|
||||
converted = self._convert_content_str_to_input_text(str(item.get("text", item)), role)
|
||||
result.append(converted)
|
||||
verbose_logger.debug(
|
||||
f"Chat provider: unknown({original_type}) -> {converted}"
|
||||
)
|
||||
verbose_logger.debug(f"Chat provider: unknown({original_type}) -> {converted}")
|
||||
verbose_logger.debug(f"Chat provider: Final converted content: {result}")
|
||||
return result
|
||||
else:
|
||||
@@ -694,17 +647,13 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
verbose_logger.debug(f"Chat provider: Other content type -> {result}")
|
||||
return result
|
||||
|
||||
def _convert_tools_to_responses_format(
|
||||
self, tools: List[Dict[str, Any]]
|
||||
) -> List["ALL_RESPONSES_API_TOOL_PARAMS"]:
|
||||
def _convert_tools_to_responses_format(self, tools: List[Dict[str, Any]]) -> List["ALL_RESPONSES_API_TOOL_PARAMS"]:
|
||||
"""Convert chat completion tools to responses API tools format"""
|
||||
responses_tools: List["ALL_RESPONSES_API_TOOL_PARAMS"] = []
|
||||
for tool in tools:
|
||||
# convert function tool from chat completion to responses API format
|
||||
if tool.get("type") == "function":
|
||||
function_tool = cast(
|
||||
ChatCompletionToolParamFunctionChunk, tool.get("function")
|
||||
)
|
||||
function_tool = cast(ChatCompletionToolParamFunctionChunk, tool.get("function"))
|
||||
responses_tools.append(
|
||||
FunctionToolParam(
|
||||
name=function_tool["name"],
|
||||
@@ -730,9 +679,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
if not extra_body:
|
||||
return optional_params
|
||||
|
||||
supported_responses_api_params = set(
|
||||
ResponsesAPIOptionalRequestParams.__annotations__.keys()
|
||||
)
|
||||
supported_responses_api_params = set(ResponsesAPIOptionalRequestParams.__annotations__.keys())
|
||||
# Also include params we handle specially
|
||||
supported_responses_api_params.update(
|
||||
{
|
||||
@@ -750,9 +697,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
|
||||
return optional_params
|
||||
|
||||
def _map_reasoning_effort(
|
||||
self, reasoning_effort: Union[str, Dict[str, Any]]
|
||||
) -> Optional[Reasoning]:
|
||||
def _map_reasoning_effort(self, reasoning_effort: Union[str, Dict[str, Any]]) -> Optional[Reasoning]:
|
||||
# If dict is passed, convert it directly to Reasoning object
|
||||
if isinstance(reasoning_effort, dict):
|
||||
return Reasoning(**reasoning_effort) # type: ignore[typeddict-item]
|
||||
@@ -760,8 +705,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
# Check if auto-summary is enabled via flag or environment variable
|
||||
# Priority: litellm.reasoning_auto_summary flag > LITELLM_REASONING_AUTO_SUMMARY env var
|
||||
auto_summary_enabled = (
|
||||
litellm.reasoning_auto_summary
|
||||
or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
|
||||
litellm.reasoning_auto_summary or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# If string is passed, map with optional summary based on flag/env var
|
||||
@@ -772,11 +716,15 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
elif reasoning_effort == "xhigh":
|
||||
return Reasoning(effort="xhigh", summary="detailed") if auto_summary_enabled else Reasoning(effort="xhigh") # type: ignore[typeddict-item]
|
||||
elif reasoning_effort == "medium":
|
||||
return Reasoning(effort="medium", summary="detailed") if auto_summary_enabled else Reasoning(effort="medium")
|
||||
return (
|
||||
Reasoning(effort="medium", summary="detailed") if auto_summary_enabled else Reasoning(effort="medium")
|
||||
)
|
||||
elif reasoning_effort == "low":
|
||||
return Reasoning(effort="low", summary="detailed") if auto_summary_enabled else Reasoning(effort="low")
|
||||
elif reasoning_effort == "minimal":
|
||||
return Reasoning(effort="minimal", summary="detailed") if auto_summary_enabled else Reasoning(effort="minimal")
|
||||
return (
|
||||
Reasoning(effort="minimal", summary="detailed") if auto_summary_enabled else Reasoning(effort="minimal")
|
||||
)
|
||||
return None
|
||||
|
||||
def _add_web_search_tool(
|
||||
@@ -855,7 +803,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
return {"format": {"type": "text"}}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _convert_annotations_to_chat_format(
|
||||
annotations: Optional[List[Any]],
|
||||
@@ -908,9 +856,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
|
||||
|
||||
class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
||||
def __init__(
|
||||
self, streaming_response, sync_stream: bool, json_mode: Optional[bool] = False
|
||||
):
|
||||
def __init__(self, streaming_response, sync_stream: bool, json_mode: Optional[bool] = False):
|
||||
super().__init__(streaming_response, sync_stream, json_mode)
|
||||
|
||||
def _handle_string_chunk(
|
||||
@@ -923,9 +869,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
||||
|
||||
if not str_line or str_line.startswith("event:"):
|
||||
# ignore.
|
||||
return GenericStreamingChunk(
|
||||
text="", tool_use=None, is_finished=False, finish_reason="", usage=None
|
||||
)
|
||||
return GenericStreamingChunk(text="", tool_use=None, is_finished=False, finish_reason="", usage=None)
|
||||
index = str_line.find("data:")
|
||||
if index != -1:
|
||||
str_line = str_line[index + 5 :]
|
||||
@@ -988,13 +932,9 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
||||
if output_item.get("type") == "function_call":
|
||||
# Extract provider_specific_fields if present
|
||||
provider_specific_fields = output_item.get("provider_specific_fields")
|
||||
if provider_specific_fields and not isinstance(
|
||||
provider_specific_fields, dict
|
||||
):
|
||||
if provider_specific_fields and not isinstance(provider_specific_fields, dict):
|
||||
provider_specific_fields = (
|
||||
dict(provider_specific_fields)
|
||||
if hasattr(provider_specific_fields, "__dict__")
|
||||
else {}
|
||||
dict(provider_specific_fields) if hasattr(provider_specific_fields, "__dict__") else {}
|
||||
)
|
||||
|
||||
function_chunk = ChatCompletionToolCallFunctionChunk(
|
||||
@@ -1003,9 +943,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
||||
)
|
||||
|
||||
if provider_specific_fields:
|
||||
function_chunk["provider_specific_fields"] = (
|
||||
provider_specific_fields
|
||||
)
|
||||
function_chunk["provider_specific_fields"] = provider_specific_fields
|
||||
|
||||
tool_call_chunk = ChatCompletionToolCallChunk(
|
||||
id=output_item.get("call_id"),
|
||||
@@ -1040,9 +978,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
||||
id=None,
|
||||
index=0,
|
||||
type="function",
|
||||
function=ChatCompletionToolCallFunctionChunk(
|
||||
name=None, arguments=content_part
|
||||
),
|
||||
function=ChatCompletionToolCallFunctionChunk(name=None, arguments=content_part),
|
||||
)
|
||||
]
|
||||
),
|
||||
@@ -1051,22 +987,16 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
||||
]
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Chat provider: Invalid function argument delta {parsed_chunk}"
|
||||
)
|
||||
raise ValueError(f"Chat provider: Invalid function argument delta {parsed_chunk}")
|
||||
elif event_type == "response.output_item.done":
|
||||
# New output item added
|
||||
output_item = parsed_chunk.get("item", {})
|
||||
if output_item.get("type") == "function_call":
|
||||
# Extract provider_specific_fields if present
|
||||
provider_specific_fields = output_item.get("provider_specific_fields")
|
||||
if provider_specific_fields and not isinstance(
|
||||
provider_specific_fields, dict
|
||||
):
|
||||
if provider_specific_fields and not isinstance(provider_specific_fields, dict):
|
||||
provider_specific_fields = (
|
||||
dict(provider_specific_fields)
|
||||
if hasattr(provider_specific_fields, "__dict__")
|
||||
else {}
|
||||
dict(provider_specific_fields) if hasattr(provider_specific_fields, "__dict__") else {}
|
||||
)
|
||||
|
||||
function_chunk = ChatCompletionToolCallFunctionChunk(
|
||||
@@ -1076,9 +1006,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
||||
|
||||
# Add provider_specific_fields to function if present
|
||||
if provider_specific_fields:
|
||||
function_chunk["provider_specific_fields"] = (
|
||||
provider_specific_fields
|
||||
)
|
||||
function_chunk["provider_specific_fields"] = provider_specific_fields
|
||||
|
||||
tool_call_chunk = ChatCompletionToolCallChunk(
|
||||
id=output_item.get("call_id"),
|
||||
@@ -1142,21 +1070,31 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
||||
elif event_type == "response.completed":
|
||||
# Response is fully complete - now we can signal is_finished=True
|
||||
# This ensures we don't prematurely end the stream before tool_calls arrive
|
||||
|
||||
# Check if response contains function_call items in output
|
||||
# to determine correct finish_reason
|
||||
response_data = parsed_chunk.get("response", {})
|
||||
output_items = response_data.get("output", []) if response_data else []
|
||||
|
||||
has_function_calls = any(
|
||||
item.get("type") == "function_call" for item in output_items if isinstance(item, dict)
|
||||
)
|
||||
|
||||
finish_reason = "tool_calls" if has_function_calls else "stop"
|
||||
|
||||
return ModelResponseStream(
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
index=0,
|
||||
delta=Delta(content=""),
|
||||
finish_reason="stop",
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
pass
|
||||
# For any unhandled event types, create a minimal valid chunk or skip
|
||||
verbose_logger.debug(
|
||||
f"Chat provider: Unhandled event type '{event_type}', creating empty chunk"
|
||||
)
|
||||
verbose_logger.debug(f"Chat provider: Unhandled event type '{event_type}', creating empty chunk")
|
||||
|
||||
# Return a minimal valid chunk for unknown events
|
||||
return ModelResponseStream(
|
||||
@@ -1179,9 +1117,5 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
|
||||
Returns:
|
||||
ModelResponseStream: OpenAI-formatted streaming chunk
|
||||
"""
|
||||
verbose_logger.debug(
|
||||
f"Chat provider: transform_streaming_response called with chunk: {chunk}"
|
||||
)
|
||||
return OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(
|
||||
chunk
|
||||
)
|
||||
verbose_logger.debug(f"Chat provider: transform_streaming_response called with chunk: {chunk}")
|
||||
return OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(chunk)
|
||||
|
||||
+148
-108
@@ -9,9 +9,7 @@ from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../..")
|
||||
) # Adds the parent directory to the system-path
|
||||
sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to the system-path
|
||||
import litellm
|
||||
|
||||
|
||||
@@ -119,9 +117,7 @@ def test_convert_chat_completion_messages_to_responses_api_tool_result_with_imag
|
||||
function_call_output = item
|
||||
break
|
||||
|
||||
assert (
|
||||
function_call_output is not None
|
||||
), "function_call_output not found in response"
|
||||
assert function_call_output is not None, "function_call_output not found in response"
|
||||
assert function_call_output["call_id"] == "call_abc123"
|
||||
|
||||
# Check that the output is correctly transformed
|
||||
@@ -131,12 +127,8 @@ def test_convert_chat_completion_messages_to_responses_api_tool_result_with_imag
|
||||
|
||||
image_item = output[0]
|
||||
# Should be transformed to Responses API format
|
||||
assert (
|
||||
image_item["type"] == "input_image"
|
||||
), f"Expected type 'input_image', got '{image_item.get('type')}'"
|
||||
assert (
|
||||
image_item["image_url"] == test_image_base64
|
||||
), "image_url should be a flat string, not a nested object"
|
||||
assert image_item["type"] == "input_image", f"Expected type 'input_image', got '{image_item.get('type')}'"
|
||||
assert image_item["image_url"] == test_image_base64, "image_url should be a flat string, not a nested object"
|
||||
assert "detail" in image_item, "detail field should be present"
|
||||
|
||||
print("✓ Tool result with image correctly transformed to Responses API format")
|
||||
@@ -198,9 +190,7 @@ def test_convert_chat_completion_messages_to_responses_api_tool_result_with_text
|
||||
function_call_output = item
|
||||
break
|
||||
|
||||
assert (
|
||||
function_call_output is not None
|
||||
), "function_call_output not found in response"
|
||||
assert function_call_output is not None, "function_call_output not found in response"
|
||||
assert function_call_output["call_id"] == "call_abc123"
|
||||
|
||||
# Check that the output is correctly transformed to use input_text, not output_text
|
||||
@@ -210,12 +200,10 @@ def test_convert_chat_completion_messages_to_responses_api_tool_result_with_text
|
||||
|
||||
text_item = output[0]
|
||||
# Should be transformed to use input_text for tool results in Responses API format
|
||||
assert (
|
||||
text_item["type"] == "input_text"
|
||||
), f"Expected type 'input_text' for tool result, got '{text_item.get('type')}'"
|
||||
assert (
|
||||
text_item["text"] == "15 degrees"
|
||||
), f"Expected text '15 degrees', got '{text_item.get('text')}'"
|
||||
assert text_item["type"] == "input_text", (
|
||||
f"Expected type 'input_text' for tool result, got '{text_item.get('type')}'"
|
||||
)
|
||||
assert text_item["text"] == "15 degrees", f"Expected text '15 degrees', got '{text_item.get('text')}'"
|
||||
|
||||
print("✓ Tool result with text correctly transformed to use input_text for Responses API format")
|
||||
|
||||
@@ -226,9 +214,7 @@ def test_openai_responses_chunk_parser_reasoning_summary():
|
||||
)
|
||||
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
|
||||
chunk = {
|
||||
"delta": "**Compar",
|
||||
@@ -260,9 +246,7 @@ def test_chunk_parser_string_output_text_delta_produces_text():
|
||||
)
|
||||
from litellm.types.utils import ModelResponseStream
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
|
||||
chunk = {"type": "response.output_text.delta", "delta": "literal text"}
|
||||
|
||||
@@ -283,9 +267,7 @@ def test_chunk_parser_enum_output_text_delta_produces_text():
|
||||
from litellm.types.llms.openai import ResponsesAPIStreamEvents
|
||||
from litellm.types.utils import ModelResponseStream
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
|
||||
chunk = {"type": ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, "delta": "enum text"}
|
||||
|
||||
@@ -306,9 +288,7 @@ def test_chunk_parser_function_call_added_produces_tool_use():
|
||||
from litellm.types.llms.openai import ResponsesAPIStreamEvents
|
||||
from litellm.types.utils import ModelResponseStream
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
|
||||
chunk = {
|
||||
"type": ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
|
||||
@@ -393,9 +373,7 @@ Tomorrow will bring its petitions and promises,
|
||||
but for now the city breathes slow and wide,
|
||||
and I learn to carry this small calm home."""
|
||||
|
||||
output_text = ResponseOutputText(
|
||||
annotations=[], text=poem_text, type="output_text", logprobs=[]
|
||||
)
|
||||
output_text = ResponseOutputText(annotations=[], text=poem_text, type="output_text", logprobs=[])
|
||||
output_message = ResponseOutputMessage(
|
||||
id="msg_04c8021b8b3188a00068e9ae0b92f4819dac64d85b4abb67ec",
|
||||
content=[output_text],
|
||||
@@ -407,9 +385,7 @@ and I learn to carry this small calm home."""
|
||||
# Create usage information
|
||||
usage = ResponseAPIUsage(
|
||||
input_tokens=16,
|
||||
input_tokens_details=InputTokensDetails(
|
||||
audio_tokens=None, cached_tokens=0, text_tokens=None
|
||||
),
|
||||
input_tokens_details=InputTokensDetails(audio_tokens=None, cached_tokens=0, text_tokens=None),
|
||||
output_tokens=195,
|
||||
output_tokens_details=OutputTokensDetails(reasoning_tokens=0, text_tokens=None),
|
||||
total_tokens=211,
|
||||
@@ -621,9 +597,7 @@ def test_transform_request_single_char_keys_not_matched():
|
||||
assert result_correct.get("metadata") == {"user_id": "123"}
|
||||
assert result_correct.get("previous_response_id") == "resp_abc"
|
||||
|
||||
print(
|
||||
"✓ Single-character keys are not incorrectly matched to metadata/previous_response_id"
|
||||
)
|
||||
print("✓ Single-character keys are not incorrectly matched to metadata/previous_response_id")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -643,9 +617,7 @@ def test_message_done_does_not_emit_is_finished():
|
||||
OpenAiResponsesToChatCompletionStreamIterator,
|
||||
)
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
|
||||
chunk = {
|
||||
"type": "response.output_item.done",
|
||||
@@ -657,9 +629,9 @@ def test_message_done_does_not_emit_is_finished():
|
||||
# After the fix, message completion should NOT set finish_reason
|
||||
# ModelResponseStream doesn't have is_finished - check finish_reason instead
|
||||
assert len(result.choices) > 0, "result should have choices"
|
||||
assert (
|
||||
result.choices[0].finish_reason is None or result.choices[0].finish_reason == ""
|
||||
), "message completion should not emit finish_reason"
|
||||
assert result.choices[0].finish_reason is None or result.choices[0].finish_reason == "", (
|
||||
"message completion should not emit finish_reason"
|
||||
)
|
||||
|
||||
|
||||
def test_response_completed_emits_is_finished():
|
||||
@@ -671,9 +643,7 @@ def test_response_completed_emits_is_finished():
|
||||
OpenAiResponsesToChatCompletionStreamIterator,
|
||||
)
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
|
||||
chunk = {"type": "response.completed"}
|
||||
|
||||
@@ -681,9 +651,91 @@ def test_response_completed_emits_is_finished():
|
||||
|
||||
# response.completed should emit finish_reason='stop'
|
||||
assert len(result.choices) > 0, "result should have choices"
|
||||
assert (
|
||||
result.choices[0].finish_reason == "stop"
|
||||
), "response.completed should emit finish_reason='stop'"
|
||||
assert result.choices[0].finish_reason == "stop", "response.completed should emit finish_reason='stop'"
|
||||
|
||||
|
||||
def test_response_completed_with_function_calls_emits_tool_calls_finish_reason():
|
||||
"""
|
||||
Test that response.completed with function_call items in output emits finish_reason='tool_calls'.
|
||||
|
||||
This is a regression test for an issue where response.completed always returned
|
||||
finish_reason='stop' even when the response contained tool calls, causing agents
|
||||
like OpenCode to incorrectly conclude the stream ended without tools to execute.
|
||||
|
||||
When the response.completed event includes function_call items in its output,
|
||||
the finish_reason should be 'tool_calls' to signal the client that tools need
|
||||
to be executed.
|
||||
"""
|
||||
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
||||
OpenAiResponsesToChatCompletionStreamIterator,
|
||||
)
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
|
||||
# Simulate a response.completed event with function_call in output
|
||||
# This matches what Azure/OpenAI sends for gpt-5.1-codex-mini and similar models
|
||||
chunk = {
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_123",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": "call_abc123",
|
||||
"call_id": "call_abc123",
|
||||
"name": "read_file",
|
||||
"arguments": '{"path": "/tmp/test.py"}',
|
||||
"status": "completed",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
result = iterator.chunk_parser(chunk)
|
||||
|
||||
# response.completed with function_call should emit finish_reason='tool_calls'
|
||||
assert len(result.choices) > 0, "result should have choices"
|
||||
assert result.choices[0].finish_reason == "tool_calls", (
|
||||
"response.completed with function_call output should emit finish_reason='tool_calls'"
|
||||
)
|
||||
|
||||
|
||||
def test_response_completed_with_message_only_emits_stop_finish_reason():
|
||||
"""
|
||||
Test that response.completed with only message output (no function_call) emits finish_reason='stop'.
|
||||
"""
|
||||
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
||||
OpenAiResponsesToChatCompletionStreamIterator,
|
||||
)
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
|
||||
# Simulate a response.completed event with only message output
|
||||
chunk = {
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_456",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_xyz",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Hello, world!"}],
|
||||
"status": "completed",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
result = iterator.chunk_parser(chunk)
|
||||
|
||||
# response.completed with only message should emit finish_reason='stop'
|
||||
assert len(result.choices) > 0, "result should have choices"
|
||||
assert result.choices[0].finish_reason == "stop", (
|
||||
"response.completed with only message output should emit finish_reason='stop'"
|
||||
)
|
||||
|
||||
|
||||
def test_function_call_done_emits_is_finished():
|
||||
@@ -695,9 +747,7 @@ def test_function_call_done_emits_is_finished():
|
||||
OpenAiResponsesToChatCompletionStreamIterator,
|
||||
)
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
|
||||
chunk = {
|
||||
"type": "response.output_item.done",
|
||||
@@ -713,13 +763,10 @@ def test_function_call_done_emits_is_finished():
|
||||
|
||||
# function_call completion should emit finish_reason='tool_calls'
|
||||
assert len(result.choices) > 0, "result should have choices"
|
||||
assert (
|
||||
result.choices[0].finish_reason == "tool_calls"
|
||||
), "function_call should emit finish_reason='tool_calls'"
|
||||
assert (
|
||||
result.choices[0].delta.tool_calls is not None
|
||||
and len(result.choices[0].delta.tool_calls) > 0
|
||||
), "function_call should include tool_calls"
|
||||
assert result.choices[0].finish_reason == "tool_calls", "function_call should emit finish_reason='tool_calls'"
|
||||
assert result.choices[0].delta.tool_calls is not None and len(result.choices[0].delta.tool_calls) > 0, (
|
||||
"function_call should include tool_calls"
|
||||
)
|
||||
|
||||
|
||||
def test_text_plus_tool_calls_sequence():
|
||||
@@ -734,9 +781,7 @@ def test_text_plus_tool_calls_sequence():
|
||||
OpenAiResponsesToChatCompletionStreamIterator,
|
||||
)
|
||||
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(
|
||||
streaming_response=None, sync_stream=True
|
||||
)
|
||||
iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
|
||||
|
||||
# Simulate the sequence from OpenAI Responses API
|
||||
chunks = [
|
||||
@@ -775,26 +820,21 @@ def test_text_plus_tool_calls_sequence():
|
||||
# Check message done (index 2) does NOT have finish_reason set
|
||||
message_done_result = results[2]
|
||||
assert len(message_done_result.choices) > 0, "message done should have choices"
|
||||
assert (
|
||||
message_done_result.choices[0].finish_reason is None
|
||||
or message_done_result.choices[0].finish_reason == ""
|
||||
), "message done should not have finish_reason"
|
||||
assert message_done_result.choices[0].finish_reason is None or message_done_result.choices[0].finish_reason == "", (
|
||||
"message done should not have finish_reason"
|
||||
)
|
||||
|
||||
# Check function_call done (index 5) DOES have finish_reason='tool_calls'
|
||||
function_done_result = results[5]
|
||||
assert (
|
||||
len(function_done_result.choices) > 0
|
||||
), "function_call done should have choices"
|
||||
assert (
|
||||
function_done_result.choices[0].finish_reason == "tool_calls"
|
||||
), "function_call done should have finish_reason='tool_calls'"
|
||||
assert len(function_done_result.choices) > 0, "function_call done should have choices"
|
||||
assert function_done_result.choices[0].finish_reason == "tool_calls", (
|
||||
"function_call done should have finish_reason='tool_calls'"
|
||||
)
|
||||
|
||||
# Check response.completed (index 6) has finish_reason='stop'
|
||||
completed_result = results[6]
|
||||
assert len(completed_result.choices) > 0, "response.completed should have choices"
|
||||
assert (
|
||||
completed_result.choices[0].finish_reason == "stop"
|
||||
), "response.completed should have finish_reason='stop'"
|
||||
assert completed_result.choices[0].finish_reason == "stop", "response.completed should have finish_reason='stop'"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1012,11 +1052,11 @@ def test_multiple_tool_calls_in_single_choice():
|
||||
def test_map_reasoning_effort_adds_summary_detailed():
|
||||
"""
|
||||
Test that _map_reasoning_effort behavior with reasoning_auto_summary flag.
|
||||
|
||||
|
||||
By default (flag=False), summary should NOT be added to avoid:
|
||||
1. Breaking for users without verified OpenAI orgs (400 errors)
|
||||
2. Making requests more expensive by including summary reasoning tokens
|
||||
|
||||
|
||||
When flag is enabled (flag=True or env var), summary="detailed" is added.
|
||||
"""
|
||||
import os
|
||||
@@ -1030,64 +1070,68 @@ def test_map_reasoning_effort_adds_summary_detailed():
|
||||
|
||||
# Test all string effort levels - DEFAULT BEHAVIOR (no summary)
|
||||
effort_levels = ["none", "low", "medium", "high", "xhigh", "minimal"]
|
||||
|
||||
|
||||
# Save original flag value
|
||||
original_flag = litellm.reasoning_auto_summary
|
||||
original_env = os.environ.get("LITELLM_REASONING_AUTO_SUMMARY")
|
||||
|
||||
|
||||
try:
|
||||
# Test 1: Default behavior (flag=False, no env var) - NO summary
|
||||
litellm.reasoning_auto_summary = False
|
||||
if "LITELLM_REASONING_AUTO_SUMMARY" in os.environ:
|
||||
del os.environ["LITELLM_REASONING_AUTO_SUMMARY"]
|
||||
|
||||
|
||||
for effort in effort_levels:
|
||||
result = handler._map_reasoning_effort(effort)
|
||||
|
||||
|
||||
assert result is not None, f"Result should not be None for effort={effort}"
|
||||
assert result["effort"] == effort, f"Effort should be {effort}"
|
||||
assert "summary" not in result, f"Summary should NOT be present by default for effort={effort}"
|
||||
|
||||
|
||||
print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}' (no summary by default)")
|
||||
|
||||
|
||||
# Test 2: With flag enabled - summary IS added
|
||||
litellm.reasoning_auto_summary = True
|
||||
|
||||
|
||||
for effort in effort_levels:
|
||||
result = handler._map_reasoning_effort(effort)
|
||||
|
||||
|
||||
assert result is not None, f"Result should not be None for effort={effort}"
|
||||
assert result["effort"] == effort, f"Effort should be {effort}"
|
||||
assert result["summary"] == "detailed", f"Summary should be 'detailed' when flag is enabled for effort={effort}"
|
||||
|
||||
print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}', summary='detailed' (flag enabled)")
|
||||
|
||||
assert result["summary"] == "detailed", (
|
||||
f"Summary should be 'detailed' when flag is enabled for effort={effort}"
|
||||
)
|
||||
|
||||
print(
|
||||
f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}', summary='detailed' (flag enabled)"
|
||||
)
|
||||
|
||||
# Test 3: With env var enabled (flag disabled) - summary IS added
|
||||
litellm.reasoning_auto_summary = False
|
||||
os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = "true"
|
||||
|
||||
|
||||
result = handler._map_reasoning_effort("high")
|
||||
assert result["summary"] == "detailed", "Summary should be 'detailed' when env var is enabled"
|
||||
print("✓ LITELLM_REASONING_AUTO_SUMMARY env var works correctly")
|
||||
|
||||
|
||||
# Test 4: Dict input is passed through as-is (no modification)
|
||||
litellm.reasoning_auto_summary = False
|
||||
if "LITELLM_REASONING_AUTO_SUMMARY" in os.environ:
|
||||
del os.environ["LITELLM_REASONING_AUTO_SUMMARY"]
|
||||
|
||||
|
||||
dict_input = {"effort": "high", "summary": "custom_summary"}
|
||||
result_dict = handler._map_reasoning_effort(dict_input)
|
||||
assert result_dict["effort"] == "high"
|
||||
assert result_dict["summary"] == "custom_summary"
|
||||
print("✓ Dict input is passed through without modification")
|
||||
|
||||
|
||||
# Test 5: None/unknown values return None
|
||||
result_unknown = handler._map_reasoning_effort("unknown_value")
|
||||
assert result_unknown is None
|
||||
print("✓ Unknown reasoning_effort values return None")
|
||||
|
||||
|
||||
print("✓ All reasoning_effort behaviors work correctly with flag/env var control")
|
||||
|
||||
|
||||
finally:
|
||||
# Restore original values
|
||||
litellm.reasoning_auto_summary = original_flag
|
||||
@@ -1100,10 +1144,10 @@ def test_map_reasoning_effort_adds_summary_detailed():
|
||||
def test_transform_response_preserves_annotations():
|
||||
"""
|
||||
Test that annotations from Responses API are preserved when transforming to Chat Completions format.
|
||||
|
||||
|
||||
This is a regression test for the bug where annotations (like url_citation) were being
|
||||
dropped during the transformation from ResponsesAPIResponse to ModelResponse.
|
||||
|
||||
|
||||
The fix ensures annotations are extracted from ResponseOutputText content items and
|
||||
passed through to the Message object in the Chat Completions response.
|
||||
"""
|
||||
@@ -1162,13 +1206,9 @@ def test_transform_response_preserves_annotations():
|
||||
# Create usage information
|
||||
usage = ResponseAPIUsage(
|
||||
input_tokens=10,
|
||||
input_tokens_details=InputTokensDetails(
|
||||
audio_tokens=None, cached_tokens=0, text_tokens=None
|
||||
),
|
||||
input_tokens_details=InputTokensDetails(audio_tokens=None, cached_tokens=0, text_tokens=None),
|
||||
output_tokens=20,
|
||||
output_tokens_details=OutputTokensDetails(
|
||||
reasoning_tokens=0, text_tokens=None
|
||||
),
|
||||
output_tokens_details=OutputTokensDetails(reasoning_tokens=0, text_tokens=None),
|
||||
total_tokens=30,
|
||||
cost=None,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user