diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index f6dd90077b1..73b9d0c8192 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -42,7 +42,7 @@ "limit": 18 }, "reportIndexIssue": { - "limit": 37 + "limit": 36 }, "reportInvalidTypeForm": { "limit": 35 @@ -114,10 +114,10 @@ "limit": 31978 }, "reportUnnecessaryCast": { - "limit": 177 + "limit": 175 }, "reportUnnecessaryComparison": { - "limit": 1021 + "limit": 1019 }, "reportUnnecessaryContains": { "limit": 7 diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 768bf6c3e66..d999c9f4e60 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -4,6 +4,7 @@ Handler for transforming /chat/completions api requests to litellm.responses req import json import os +from collections.abc import Mapping from typing import ( TYPE_CHECKING, Any, @@ -21,6 +22,13 @@ from typing import ( ) from openai.types.responses.custom_tool_param import CustomToolParam +from openai.types.responses.response_input_param import ( + FunctionCallOutput, + ResponseCustomToolCallOutputParam, + ResponseCustomToolCallParam, +) +from openai.types.responses.tool_choice_custom_param import ToolChoiceCustomParam +from openai.types.responses.tool_choice_function_param import ToolChoiceFunctionParam from openai.types.responses.tool_param import FunctionToolParam from pydantic import BaseModel @@ -40,6 +48,8 @@ from litellm.responses.utils import normalize_responses_api_stream_options from litellm.types.llms.openai import ( ChatCompletionAnnotation, ChatCompletionReasoningItem, + ChatCompletionToolCallChunk, + ChatCompletionToolCallFunctionChunk, ChatCompletionToolParamFunctionChunk, Reasoning, ResponsesAPIOptionalRequestParams, @@ -101,7 +111,11 @@ def _build_reasoning_item( } -def _tool_call_dict_from_output_item(item: dict[str, Any]) -> dict[str, Any]: +class _ChatToolCallDict(ChatCompletionToolCallChunk, total=False): + provider_specific_fields: Mapping[str, Any] + + +def _tool_call_dict_from_output_item(item: Mapping[str, Any], index: int) -> _ChatToolCallDict: """Convert a ``function_call`` or ``custom_tool_call`` output item dict to a chat completions tool_call dict. Custom (grammar/freeform) tool calls carry their raw string payload in ``input`` rather than ``arguments``; both map to @@ -115,22 +129,32 @@ def _tool_call_dict_from_output_item(item: dict[str, Any]) -> dict[str, Any]: is_custom = item.get("type") == "custom_tool_call" arguments = (item.get("input") if is_custom else item.get("arguments")) or "" name = item.get("name") or ("custom_tool" if is_custom else "") - tool_call_dict: dict[str, Any] = { - "id": LiteLLMCompletionResponsesConfig._tool_call_id_from_responses_item(item.get("id"), item.get("call_id")), - "function": {"name": name, "arguments": arguments}, - "type": "function", - } - provider_specific_fields = item.get("provider_specific_fields") - 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 None - ) + function_chunk = ChatCompletionToolCallFunctionChunk(name=name, arguments=arguments) + tool_call_dict = _ChatToolCallDict( + id=LiteLLMCompletionResponsesConfig._tool_call_id_from_responses_item(item.get("id"), item.get("call_id")), + type="function", + function=function_chunk, + index=index, + ) + raw_provider_fields = item.get("provider_specific_fields") + if isinstance(raw_provider_fields, dict): + provider_specific_fields = raw_provider_fields + elif raw_provider_fields and hasattr(raw_provider_fields, "__dict__"): + provider_specific_fields = vars(raw_provider_fields) + else: + provider_specific_fields = None if provider_specific_fields: tool_call_dict["provider_specific_fields"] = provider_specific_fields - tool_call_dict["function"]["provider_specific_fields"] = provider_specific_fields + function_chunk["provider_specific_fields"] = provider_specific_fields return tool_call_dict +def _flat_responses_tool_choice(choice_type: str, name: str) -> Union[ToolChoiceFunctionParam, ToolChoiceCustomParam]: + if choice_type == "custom": + return ToolChoiceCustomParam(type="custom", name=name) + return ToolChoiceFunctionParam(type="function", name=name) + + def _reasoning_item_to_response_input( r_item: Union[ChatCompletionReasoningItem, Dict[str, Any]], ) -> Dict[str, Any]: @@ -163,12 +187,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): return tool_choice if isinstance(tool_choice.get("name"), str) and tool_choice.get("name"): # Return only Responses shape so stray chat ``function``/``custom`` keys are not sent upstream. - return {"type": choice_type, "name": tool_choice["name"]} + return _flat_responses_tool_choice(choice_type, tool_choice["name"]) nested = tool_choice.get(choice_type) if isinstance(nested, dict): nested_name = nested.get("name") if isinstance(nested_name, str) and nested_name: - return {"type": choice_type, "name": nested_name} + return _flat_responses_tool_choice(choice_type, nested_name) return tool_choice def _handle_raw_dict_response_item(self, item: Dict[str, Any], index: int) -> Tuple[Optional[Any], int]: @@ -221,7 +245,15 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): ) -> Tuple[List[Any], Optional[str]]: input_items: List[Any] = [] instructions: Optional[str] = None - custom_tool_call_ids: set = set() + custom_tool_call_ids = frozenset( + tool_call["id"] + for msg in messages + if msg.get("role") == "assistant" and isinstance(msg.get("tool_calls"), list) + for tool_call in msg.get("tool_calls") or () + if isinstance(tool_call, dict) + and not tool_call.get("function") + and isinstance(tool_call.get("custom"), dict) + ) for msg in messages: role = msg.get("role") @@ -269,19 +301,19 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): tool_output = [{"type": "input_text", "text": str(content)}] if tool_call_id in custom_tool_call_ids: input_items.append( - { - "type": "custom_tool_call_output", - "call_id": tool_call_id, - "output": content if isinstance(content, str) else tool_output, - } + ResponseCustomToolCallOutputParam( + type="custom_tool_call_output", + call_id=tool_call_id, + output=content if isinstance(content, str) else tool_output, + ) ) else: input_items.append( - { - "type": "function_call_output", - "call_id": tool_call_id, - "output": tool_output, - } + FunctionCallOutput( + type="function_call_output", + call_id=tool_call_id, + output=tool_output, + ) ) elif role == "assistant" and tool_calls and isinstance(tool_calls, list): for r_item in _get_reasoning_items(msg): @@ -300,14 +332,13 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): input_tool_call["arguments"] = function["arguments"] input_items.append(input_tool_call) elif isinstance(custom, dict): - custom_tool_call_ids.add(tool_call["id"]) input_items.append( - { - "type": "custom_tool_call", - "call_id": tool_call["id"], - "name": custom.get("name", ""), - "input": custom.get("input", ""), - } + ResponseCustomToolCallParam( + type="custom_tool_call", + call_id=tool_call["id"], + name=custom.get("name", ""), + input=custom.get("input", ""), + ) ) else: raise ValueError(f"tool call not supported: {tool_call}") @@ -598,7 +629,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): # Tool calls accumulate into the single trailing tool_calls choice # like the typed branches above; a choice per call would hide every # call after choices[0] from chat clients - accumulated_tool_calls.append(_tool_call_dict_from_output_item(raw_item)) + accumulated_tool_calls.append(_tool_call_dict_from_output_item(raw_item, tool_call_index)) tool_call_index += 1 elif handle_raw_dict_callback is not None: choice, index = handle_raw_dict_callback(item=raw_item, index=index) @@ -925,10 +956,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): ) custom_payload = tool["custom"] - flat_custom: CustomToolParam = { - "type": "custom", - "name": custom_payload.get("name", ""), - } + flat_custom = CustomToolParam(type="custom", name=custom_payload.get("name", "")) if custom_payload.get("description") is not None: flat_custom["description"] = custom_payload["description"] if isinstance(custom_payload.get("format"), dict): @@ -1130,7 +1158,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): def __init__(self, streaming_response, sync_stream: bool, json_mode: Optional[bool] = False): super().__init__(streaming_response, sync_stream, json_mode) self._chat_completion_id: str | None = None - self._tool_call_index_map: dict[int, int] = {} + self._tool_call_index_map: dict[int, int] = {} # mutable-ok: per-stream accumulator state def _handle_string_chunk( self, str_line: Union[str, "BaseModel"] @@ -1151,7 +1179,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): @staticmethod def _sequential_tool_call_index( - tool_call_index_map: dict[int, int] | None, + tool_call_index_map: dict[int, int] | None, # mutable-ok: per-stream state, remapped in place output_index: int, ) -> int: """Chat-completions tool_call indices must be 0-based and sequential, but @@ -1170,7 +1198,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): @staticmethod def translate_responses_chunk_to_openai_stream( parsed_chunk: Union[dict, BaseModel], - tool_call_index_map: dict[int, int] | None = None, + tool_call_index_map: dict[int, int] | None = None, # mutable-ok: per-stream state, remapped in place ) -> "ModelResponseStream": """ Translate a Responses API streaming chunk to OpenAI chat completion streaming format. @@ -1229,7 +1257,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): # New output item added output_item = parsed_chunk.get("item", {}) if output_item.get("type") in ("function_call", "custom_tool_call"): - converted = _tool_call_dict_from_output_item(output_item) + converted = _tool_call_dict_from_output_item(output_item, parsed_chunk.get("output_index", 0)) provider_specific_fields = converted.get("provider_specific_fields") function_chunk = ChatCompletionToolCallFunctionChunk( @@ -1299,16 +1327,15 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): # tool call; per-stream callers already received it via # output_item.added and the argument delta events return ModelResponseStream( - choices=[ + choices=[ # mutable-ok: ModelResponseStream coerces only list choices StreamingChoices( index=0, delta=Delta( - tool_calls=[ - { - **_tool_call_dict_from_output_item(dict(output_item)), - "index": parsed_chunk.get("output_index", 0), - } - ] + tool_calls=( + _tool_call_dict_from_output_item( + output_item, parsed_chunk.get("output_index", 0) + ), + ) ), finish_reason=None, ) diff --git a/litellm/integrations/helicone.py b/litellm/integrations/helicone.py index c9346f7e6cf..5d072ad873c 100644 --- a/litellm/integrations/helicone.py +++ b/litellm/integrations/helicone.py @@ -61,24 +61,19 @@ class HeliconeLogger: for tool_call in message["tool_calls"]: function = tool_call.get("function") custom = tool_call.get("custom") - if function: - content.append( - { - "type": "tool_use", - "id": tool_call["id"], - "name": function["name"], - "input": function["arguments"], - } - ) - elif custom: - content.append( - { - "type": "tool_use", - "id": tool_call["id"], - "name": custom["name"], - "input": custom["input"], - } - ) + if not function and not custom: + continue + name, tool_input = ( + (function["name"], function["arguments"]) if function else (custom["name"], custom["input"]) + ) + content.append( + { + "type": "tool_use", + "id": tool_call["id"], + "name": name, + "input": tool_input, + } + ) elif "content" in message and message["content"]: content = [{"type": "text", "text": message["content"]}] diff --git a/litellm/integrations/lunary.py b/litellm/integrations/lunary.py index 94cb5bab8fe..02a035bc445 100644 --- a/litellm/integrations/lunary.py +++ b/litellm/integrations/lunary.py @@ -22,25 +22,18 @@ def parse_tool_calls(tool_calls): def clean_tool_call(tool_call): custom = getattr(tool_call, "custom", None) if custom is not None: - return { - "type": tool_call.type, - "id": tool_call.id, - "function": { - "name": custom.name, - "arguments": custom.input, - }, - } - serialized = { + name, arguments = custom.name, custom.input + else: + name, arguments = tool_call.function.name, tool_call.function.arguments + return { "type": tool_call.type, "id": tool_call.id, "function": { - "name": tool_call.function.name, - "arguments": tool_call.function.arguments, + "name": name, + "arguments": arguments, }, } - return serialized - return [ clean_tool_call(tool_call) for tool_call in tool_calls diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index 1b23db87264..cf3937072c2 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -3,6 +3,7 @@ import json import re import time import traceback +from collections.abc import Sequence from typing import Dict, Iterable, List, Literal, Optional, Tuple, Union, cast import litellm @@ -371,7 +372,9 @@ from collections import defaultdict def _handle_invalid_parallel_tool_calls( - tool_calls: List[Union[ChatCompletionMessageToolCall, ChatCompletionMessageCustomToolCall]], + tool_calls: List[ + Union[ChatCompletionMessageToolCall, ChatCompletionMessageCustomToolCall] + ], # mutable-ok: patched in place via slice assignment ): """ Handle hallucinated parallel tool call from openai - https://community.openai.com/t/model-tries-to-call-unknown-function-multi-tool-use-parallel/490653 @@ -532,7 +535,7 @@ class LiteLLMResponseObjectHandler: def _should_convert_tool_call_to_json_mode( tool_calls: ( - list[ChatCompletionMessageToolCall | ChatCompletionMessageCustomToolCall] | list[DatabricksTool] | None + Sequence[ChatCompletionMessageToolCall | ChatCompletionMessageCustomToolCall] | Sequence[DatabricksTool] | None ) = None, convert_tool_call_to_json_mode: Optional[bool] = None, ) -> bool: diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 3a7a710c6a9..52974d42b96 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -21,6 +21,14 @@ from typing import ( cast, ) +from openai.types.chat.chat_completion_custom_tool_param import ( + CustomFormatGrammar, + CustomFormatGrammarGrammar, +) +from openai.types.shared_params.custom_tool_input_format import ( + Grammar as ResponsesGrammarFormat, +) + import litellm from litellm import verbose_logger from litellm.router_utils.batch_utils import InMemoryFile @@ -1252,29 +1260,36 @@ def is_function_call(optional_params: dict) -> bool: return False -def convert_custom_tool_format_to_chat_shape(format_obj: dict) -> dict: +def convert_custom_tool_format_to_chat_shape(format_obj: Mapping[str, Any]) -> Mapping[str, Any]: """ Responses API grammar formats are flat ({"type": "grammar", "definition", "syntax"}); Chat Completions wraps the same fields in a "grammar" object. Text formats are identical on both surfaces and pass through, as does anything unrecognized. """ - if format_obj.get("type") == "grammar" and "grammar" not in format_obj: - return { - "type": "grammar", - "grammar": {k: format_obj[k] for k in ("definition", "syntax") if k in format_obj}, - } - return format_obj + if format_obj.get("type") != "grammar" or "grammar" in format_obj: + return format_obj + grammar = CustomFormatGrammarGrammar() + if "definition" in format_obj: + grammar["definition"] = format_obj["definition"] + if "syntax" in format_obj: + grammar["syntax"] = format_obj["syntax"] + return CustomFormatGrammar(type="grammar", grammar=grammar) -def convert_custom_tool_format_to_responses_shape(format_obj: dict) -> dict: +def convert_custom_tool_format_to_responses_shape(format_obj: Mapping[str, Any]) -> Mapping[str, Any]: """ Inverse of convert_custom_tool_format_to_chat_shape: unwrap the Chat Completions "grammar" object into the flat Responses API grammar shape. """ grammar = format_obj.get("grammar") - if format_obj.get("type") == "grammar" and isinstance(grammar, dict): - return {"type": "grammar", **{k: grammar[k] for k in ("definition", "syntax") if k in grammar}} - return format_obj + if format_obj.get("type") != "grammar" or not isinstance(grammar, dict): + return format_obj + flat = ResponsesGrammarFormat(type="grammar") + if "definition" in grammar: + flat["definition"] = grammar["definition"] + if "syntax" in grammar: + flat["syntax"] = grammar["syntax"] + return flat def get_file_ids_from_messages(messages: List[AllMessageValues]) -> List[str]: diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index f4f1b6fca0d..6d013718668 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -1,5 +1,6 @@ import base64 import time +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast from litellm.types.llms.openai import ( @@ -205,9 +206,13 @@ class ChunkProcessor: return response def get_combined_tool_content( - self, tool_call_chunks: List[Dict[str, Any]] - ) -> List[Union[ChatCompletionMessageToolCall, ChatCompletionMessageCustomToolCall]]: - tool_calls_list: List[Union[ChatCompletionMessageToolCall, ChatCompletionMessageCustomToolCall]] = [] + self, tool_call_chunks: Sequence[Mapping[str, Any]] + ) -> List[ + Union[ChatCompletionMessageToolCall, ChatCompletionMessageCustomToolCall] + ]: # mutable-ok: assigned verbatim to Message.tool_calls, a List field + tool_calls_list: List[ + Union[ChatCompletionMessageToolCall, ChatCompletionMessageCustomToolCall] + ] = [] # mutable-ok: see return type tool_call_map: Dict[int, Dict[str, Any]] = {} # Map to store tool calls by index for chunk in tool_call_chunks: @@ -245,9 +250,9 @@ class ChunkProcessor: "id": None, "name": None, "type": None, - "arguments": [], + "arguments": (), "custom_name": None, - "custom_input": [], + "custom_input": (), "provider_specific_fields": None, } @@ -263,20 +268,20 @@ class ChunkProcessor: if function.get("name"): tool_call_map[index]["name"] = function["name"] if function.get("arguments"): - tool_call_map[index]["arguments"].append(function["arguments"]) + tool_call_map[index]["arguments"] += (function["arguments"],) else: # function is an object if hasattr(function, "name") and function.name: tool_call_map[index]["name"] = function.name if hasattr(function, "arguments") and function.arguments: - tool_call_map[index]["arguments"].append(function.arguments) + tool_call_map[index]["arguments"] += (function.arguments,) custom = tool_call.get("custom") if isinstance(custom, dict): if custom.get("name"): tool_call_map[index]["custom_name"] = custom["name"] if custom.get("input"): - tool_call_map[index]["custom_input"].append(custom["input"]) + tool_call_map[index]["custom_input"] += (custom["input"],) else: # tool_call is an object if hasattr(tool_call, "id") and tool_call.id: @@ -287,14 +292,14 @@ class ChunkProcessor: if hasattr(tool_call.function, "name") and tool_call.function.name: tool_call_map[index]["name"] = tool_call.function.name if hasattr(tool_call.function, "arguments") and tool_call.function.arguments: - tool_call_map[index]["arguments"].append(tool_call.function.arguments) + tool_call_map[index]["arguments"] += (tool_call.function.arguments,) custom = getattr(tool_call, "custom", None) if custom is not None: if getattr(custom, "name", None): tool_call_map[index]["custom_name"] = custom.name if getattr(custom, "input", None): - tool_call_map[index]["custom_input"].append(custom.input) + tool_call_map[index]["custom_input"] += (custom.input,) # Preserve provider_specific_fields from streaming chunks provider_fields = None diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index e4492a8aba6..b6c8b7f2a07 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -533,12 +533,14 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): for choice in choices: ## HANDLE JSON MODE - anthropic returns single function call] tool_calls = choice["message"].get("tool_calls", None) - new_tool_calls: list[ChatCompletionMessageToolCall | ChatCompletionMessageCustomToolCall] | None = None + new_tool_calls: list[ChatCompletionMessageToolCall | ChatCompletionMessageCustomToolCall] | None = ( + None # mutable-ok: holds _handle_invalid_parallel_tool_calls' list; Message.__init__ expects list + ) message_content = choice["message"].get("content", None) if tool_calls is not None: _openai_tool_calls = [] for _tc in tool_calls: - _openai_tc = chat_completion_tool_call_from_dict(dict(_tc)) + _openai_tc = chat_completion_tool_call_from_dict(_tc) _openai_tool_calls.append(_openai_tc) fixed_tool_calls = _handle_invalid_parallel_tool_calls(_openai_tool_calls) diff --git a/litellm/main.py b/litellm/main.py index fbb43dd41fa..cadf65c3e50 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -1058,7 +1058,7 @@ def responses_api_bridge_check( # summary alias is present with ``reasoning_effort`` (tools alone stay on chat). has_function_tool = any( (tool.get("type") == "function" if isinstance(tool, dict) else getattr(tool, "type", None) == "function") - for tool in (tools or []) + for tool in (tools or ()) ) if isinstance(reasoning_effort, dict): reasoning_active = reasoning_effort.get("effort") != "none" or reasoning_effort.get("summary") is not None diff --git a/litellm/proxy/response_api_endpoints/endpoints.py b/litellm/proxy/response_api_endpoints/endpoints.py index 3a2c57aa2ba..2068de17785 100644 --- a/litellm/proxy/response_api_endpoints/endpoints.py +++ b/litellm/proxy/response_api_endpoints/endpoints.py @@ -1,6 +1,8 @@ import asyncio import json import time +from collections.abc import Mapping +from types import MappingProxyType from typing import Any, AsyncIterator, Dict, Optional, cast from uuid import uuid4 @@ -23,19 +25,30 @@ from litellm.types.responses.main import DeleteResponseResult router = APIRouter() _user_api_key_auth_dep = Depends(user_api_key_auth) +_RESPONSES_TAGS = ["responses"] # mutable-ok: fastapi's route signature requires List[str] tags -_TOOL_PAYLOAD_KEYS = { - "custom": ("name", "description", "format"), - "function": ("name", "description", "parameters", "strict"), -} +_TOOL_PAYLOAD_KEYS: Mapping[str, tuple[str, ...]] = MappingProxyType( + { + "custom": ("name", "description", "format"), + "function": ("name", "description", "parameters", "strict"), + } +) +_EMPTY_TOOL_PAYLOAD: Mapping[str, Any] = MappingProxyType({}) -def _convert_tool_envelope(obj: object, *, to_chat: bool) -> object: +def _convert_tool_payload_value(key: str, value: object, *, to_chat: bool) -> object: + if key != "format" or not isinstance(value, dict): + return value from litellm.litellm_core_utils.prompt_templates.common_utils import ( convert_custom_tool_format_to_chat_shape, convert_custom_tool_format_to_responses_shape, ) + convert = convert_custom_tool_format_to_chat_shape if to_chat else convert_custom_tool_format_to_responses_shape + return convert(value) + + +def _convert_tool_envelope(obj: object, *, to_chat: bool) -> object: if not isinstance(obj, dict): return obj tool_type = obj.get("type") @@ -43,35 +56,37 @@ def _convert_tool_envelope(obj: object, *, to_chat: bool) -> object: if payload_keys is None: return obj nested = obj.get(tool_type) - nested_source = nested if isinstance(nested, dict) else {} - payload = { - key: nested_source[key] if key in nested_source else obj[key] + nested_source = nested if isinstance(nested, dict) else _EMPTY_TOOL_PAYLOAD + payload = { # mutable-ok: tool entries are embedded verbatim in the JSON request body + key: _convert_tool_payload_value(key, nested_source[key] if key in nested_source else obj[key], to_chat=to_chat) for key in payload_keys if key in nested_source or key in obj } if "name" not in payload: return obj - if isinstance(payload.get("format"), dict): - convert = convert_custom_tool_format_to_chat_shape if to_chat else convert_custom_tool_format_to_responses_shape - payload = {**payload, "format": convert(payload["format"])} - return {"type": tool_type, tool_type: payload} if to_chat else {"type": tool_type, **payload} + return {"type": tool_type, tool_type: payload} if to_chat else {"type": tool_type, **payload} # mutable-ok: same -def _normalize_tool_dialect(data: dict, *, to_chat: bool) -> dict: - converted: dict = {} +def _normalize_tool_dialect( + data: dict, *, to_chat: bool +) -> dict: # mutable-ok: the parsed request body contract is a plain dict tools = data.get("tools") - if isinstance(tools, list): - normalized_tools = [_convert_tool_envelope(tool, to_chat=to_chat) for tool in tools] - if normalized_tools != tools: - converted["tools"] = normalized_tools tool_choice = data.get("tool_choice") + normalized_tools = ( + [ + _convert_tool_envelope(tool, to_chat=to_chat) for tool in tools + ] # mutable-ok: body's tools stays a plain JSON list + if isinstance(tools, list) + else tools + ) normalized_choice = _convert_tool_envelope(tool_choice, to_chat=to_chat) - if normalized_choice != tool_choice: - converted["tool_choice"] = normalized_choice - return {**data, **converted} if converted else data + if normalized_tools == tools and normalized_choice == tool_choice: + return data + replaceable = (("tools", normalized_tools), ("tool_choice", normalized_choice)) + return {**data, **{key: value for key, value in replaceable if key in data}} # mutable-ok: plain body dict -def _is_chat_completions_body(data: dict) -> bool: +def _is_chat_completions_body(data: Mapping[str, Any]) -> bool: messages = data.get("messages") if isinstance(messages, list) and len(messages) > 0: return True @@ -340,13 +355,13 @@ async def responses_api( @router.get( "/cursor/models", - dependencies=[Depends(user_api_key_auth)], - tags=["responses"], + dependencies=(_user_api_key_auth_dep,), + tags=_RESPONSES_TAGS, ) @router.get( "/cursor/v1/models", - dependencies=[Depends(user_api_key_auth)], - tags=["responses"], + dependencies=(_user_api_key_auth_dep,), + tags=_RESPONSES_TAGS, ) async def cursor_model_list( user_api_key_dict: UserAPIKeyAuth = _user_api_key_auth_dep, @@ -447,7 +462,7 @@ async def cursor_chat_completions( # Rebuild rather than pop: _read_request_body can return the request-scope # cached parsed-body dict itself, and removing keys from it corrupts the # cache's key snapshot so later readers get an empty body - data = {key: value for key, value in data.items() if key != "stream_options"} + data = {key: value for key, value in data.items() if key != "stream_options"} # mutable-ok: plain body dict data = _normalize_tool_dialect(data, to_chat=False) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 176274d236f..090723edb85 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -7,6 +7,12 @@ import re from collections.abc import Sequence from typing import Any, Literal, cast +from openai.types.chat.chat_completion_named_tool_choice_param import ( + ChatCompletionNamedToolChoiceParam, +) +from openai.types.chat.chat_completion_named_tool_choice_param import ( + Function as NamedToolChoiceFunction, +) from openai.types.responses import ResponseFunctionToolCall from openai.types.responses.response_create_params import ResponseInputParam from openai.types.responses.tool_param import FunctionToolParam @@ -160,13 +166,17 @@ class LiteLLMCompletionResponsesConfig: elif tool_choice_type == "function": function_name = tool_choice.get("name") if function_name: - return {"type": "function", "function": {"name": function_name}} + return ChatCompletionNamedToolChoiceParam( + type="function", function=NamedToolChoiceFunction(name=function_name) + ) return "required" elif tool_choice_type == "custom": custom = tool_choice.get("custom") custom_name = tool_choice.get("name") or (custom.get("name") if isinstance(custom, dict) else None) if custom_name: - return {"type": "function", "function": {"name": custom_name}} + return ChatCompletionNamedToolChoiceParam( + type="function", function=NamedToolChoiceFunction(name=custom_name) + ) return "required" # Return as-is for unknown formats diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 404725ec61b..6d051b70432 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -1,6 +1,7 @@ import json import time from enum import Enum +from types import MappingProxyType from typing import ( TYPE_CHECKING, Any, @@ -1162,16 +1163,16 @@ class ChatCompletionMessageToolCall(OpenAIObject): setattr(self, key, value) -def is_custom_tool_call_dict(tool_call: dict) -> bool: +def is_custom_tool_call_dict(tool_call: Mapping[str, Any]) -> bool: return tool_call.get("type") == "custom" or tool_call.get("custom") is not None def chat_completion_tool_call_from_dict( - tool_call: dict, + tool_call: Mapping[str, Any], ) -> "ChatCompletionMessageToolCall | ChatCompletionMessageCustomToolCall": if is_custom_tool_call_dict(tool_call): return ChatCompletionMessageCustomToolCall( - **{k: v for k, v in tool_call.items() if not (k in ("function", "type") and v is None)} + **MappingProxyType({k: v for k, v in tool_call.items() if not (k in ("function", "type") and v is None)}) ) return ChatCompletionMessageToolCall(**tool_call) @@ -1228,7 +1229,9 @@ def add_provider_specific_fields(object: BaseModel, provider_specific_fields: Op class Message(SafeAttributeModel, OpenAIObject): content: Optional[str] role: Literal["assistant", "user", "system", "tool", "function"] - tool_calls: Optional[List[Union[ChatCompletionMessageToolCall, ChatCompletionMessageCustomToolCall]]] + tool_calls: Optional[ + List[Union[ChatCompletionMessageToolCall, ChatCompletionMessageCustomToolCall]] + ] # mutable-ok: public pydantic response field; only the union member is new function_call: Optional[FunctionCall] audio: Optional[ChatCompletionAudioResponse] = None images: Optional[List[ImageURLListItem]] = None @@ -1352,7 +1355,9 @@ class Delta(SafeAttributeModel, OpenAIObject): content: Optional[str] role: Optional[str] function_call: Optional[FunctionCall] - tool_calls: Optional[List[Union[ChatCompletionDeltaToolCall, ChatCompletionDeltaCustomToolCall]]] + tool_calls: Optional[ + List[Union[ChatCompletionDeltaToolCall, ChatCompletionDeltaCustomToolCall]] + ] # mutable-ok: public pydantic response field; only the union member is new audio: Optional[ChatCompletionAudioResponse] images: Optional[List[ImageURLListItem]] annotations: Optional[List[ChatCompletionAnnotation]] @@ -1389,8 +1394,10 @@ class Delta(SafeAttributeModel, OpenAIObject): if function_call is not None and isinstance(function_call, dict): function_call = FunctionCall(**function_call) - if tool_calls is not None and isinstance(tool_calls, list): - coerced_tool_calls: List[Union[ChatCompletionDeltaToolCall, ChatCompletionDeltaCustomToolCall]] = [] + if tool_calls is not None and isinstance(tool_calls, (list, tuple)): + coerced_tool_calls: List[ + Union[ChatCompletionDeltaToolCall, ChatCompletionDeltaCustomToolCall] + ] = [] # mutable-ok: public Delta.tool_calls contract is a list current_index = 0 for tool_call in tool_calls: if isinstance(tool_call, dict): @@ -1400,7 +1407,9 @@ class Delta(SafeAttributeModel, OpenAIObject): if is_custom_tool_call_dict(tool_call): coerced_tool_calls.append( ChatCompletionDeltaCustomToolCall( - **{k: v for k, v in tool_call.items() if not (k == "function" and v is None)} + **MappingProxyType( + {k: v for k, v in tool_call.items() if not (k == "function" and v is None)} + ) ) ) else: diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index b8650eea7aa..4c8132ff859 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -135,7 +135,7 @@ "limit": 30 }, "PERF401": { - "limit": 142 + "limit": 141 }, "PERF402": { "limit": 9 @@ -222,7 +222,7 @@ "limit": 38 }, "RET504": { - "limit": 702 + "limit": 701 }, "RUF010": { "limit": 874 @@ -267,7 +267,7 @@ "limit": 324 }, "SIM103": { - "limit": 129 + "limit": 128 }, "SIM113": { "limit": 6 @@ -324,7 +324,7 @@ "limit": 879 }, "UP006": { - "limit": 12050 + "limit": 12045 }, "UP007": { "limit": 2526 @@ -363,6 +363,6 @@ "limit": 104 }, "UP045": { - "limit": 17793 + "limit": 17791 } } diff --git a/type-discipline-budget.json b/type-discipline-budget.json index ff037a2872e..bc28630a4e5 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 23191 + "limit": 23180 }, "LIT002": { - "limit": 27276 + "limit": 27259 }, "LIT003": { "limit": 292 @@ -24,6 +24,6 @@ "limit": 1004 }, "LIT009": { - "limit": 2467 + "limit": 2465 } }