diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 790bd0519d74..129e15a0bf23 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5537,9 +5537,7 @@ async def async_realtime( import websockets from websockets.asyncio.client import ClientConnection - url = self._append_query_params( - provider_config.get_complete_url(api_base, model, api_key), query_params - ) + url = provider_config.get_complete_url(api_base, model, api_key) headers = provider_config.validate_environment( headers=headers, model=model, diff --git a/litellm/llms/gemini/realtime/transformation.py b/litellm/llms/gemini/realtime/transformation.py index 74f6cd4d8319..e153d00e6ab8 100644 --- a/litellm/llms/gemini/realtime/transformation.py +++ b/litellm/llms/gemini/realtime/transformation.py @@ -103,6 +103,10 @@ def __init__(self): # bypassing spend and budget accounting. self._pending_usage_metadata: Optional[dict] = None + def _include_function_response_id(self) -> bool: + """Google AI Studio Gemini 3.5+ accepts ``id`` on functionResponses; Vertex AI rejects it.""" + return True + @staticmethod def _usage_detail_alias(details: Any, defaults: Dict[str, int]) -> Dict[str, Any]: if not isinstance(details, dict): @@ -604,10 +608,9 @@ def _handle_function_call_output(self, item: dict) -> List[str]: ) # Build Gemini toolResponse format - function_response = { - "id": call_id, - "response": output_dict, - } + function_response: dict[str, Any] = {"response": output_dict} + if self._include_function_response_id() and call_id: + function_response["id"] = call_id if function_name: function_response["name"] = function_name diff --git a/litellm/llms/vertex_ai/realtime/transformation.py b/litellm/llms/vertex_ai/realtime/transformation.py index d6441db78568..1fe9f15c9f07 100644 --- a/litellm/llms/vertex_ai/realtime/transformation.py +++ b/litellm/llms/vertex_ai/realtime/transformation.py @@ -32,6 +32,9 @@ def __init__(self, access_token: str, project: str, location: str) -> None: self._project = project self._location = location + def _include_function_response_id(self) -> bool: + return False + # ------------------------------------------------------------------ # URL # ------------------------------------------------------------------ diff --git a/litellm/main.py b/litellm/main.py index 1d75766c7e6b..4fade2ac4b09 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -5044,9 +5044,7 @@ def completion( # type: ignore if LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway( tools=tools_for_mcp ): - # Return coroutine - acompletion will await it - # completion() can return a coroutine when MCP tools are present, which acompletion() awaits - return acompletion_with_mcp( # type: ignore[return-value] + return acompletion_with_mcp( # pyright: ignore[reportReturnType] # MCP path returns a coroutine that acompletion() awaits; completion()'s sync return type omits it model=model, messages=messages, functions=functions, @@ -5218,12 +5216,16 @@ def completion( # type: ignore logging: LiteLLMLoggingObj = cast(LiteLLMLoggingObj, litellm_logging_obj) fallbacks = fallbacks or litellm.model_fallbacks if fallbacks is not None: - return completion_with_fallbacks(**args) + return completion_with_fallbacks( # pyright: ignore[reportReturnType] # fallback runner is untyped; resolves to ModelResponse|CustomStreamWrapper at runtime + **args + ) if model_list is not None: deployments = [ m["litellm_params"] for m in model_list if m["model_name"] == model ] - return litellm.batch_completion_models(deployments=deployments, **args) + return litellm.batch_completion_models( # pyright: ignore[reportReturnType] # batch path returns a list of responses, outside completion()'s single-response return type + deployments=deployments, **args + ) if litellm.model_alias_map and model in litellm.model_alias_map: model = litellm.model_alias_map[ model @@ -5545,7 +5547,7 @@ def completion( # type: ignore else: optional_params["reasoning_effort"] = {"summary": rs_val} - return responses_api_bridge.completion( + return responses_api_bridge.completion( # pyright: ignore[reportReturnType] # bridge returns a coroutine on the acompletion path; awaited by the async caller model=model, messages=messages, headers=headers, diff --git a/tests/test_litellm/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py b/tests/test_litellm/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py index 1ebd704be344..1f171496cce1 100644 --- a/tests/test_litellm/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py @@ -346,3 +346,74 @@ def test_vertex_does_not_warn_when_dropping_non_guardrail_session_update(caplog) "Vertex AI Realtime" in record.message and "session.update" in record.message for record in caplog.records ) + + +async def test_async_realtime_does_not_forward_client_query_params_to_vertex_backend( + monkeypatch, +): + """Regression: forwarding client ?model=/?intent= to the Vertex Live WSS URL causes 1007 errors. + + Exercises ``async_realtime`` end-to-end so that re-adding ``_append_query_params`` + (the reverted bug) would push ``model=``/``intent=`` onto the backend URL and fail here. + """ + import websockets + + from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler + + cfg = VertexAIRealtimeConfig( + access_token="tok", project="my-proj", location="us-central1" + ) + + captured = {} + + def fake_connect(url, *args, **kwargs): + captured["url"] = url + raise RuntimeError("stop before establishing the backend connection") + + monkeypatch.setattr(websockets, "connect", fake_connect) + + await BaseLLMHTTPHandler().async_realtime( + model="gemini-live-2.5-flash-native-audio", + websocket=AsyncMock(), + logging_obj=MagicMock(), + provider_config=cfg, + headers={}, + query_params={ + "model": "gemini-live-2.5-flash-native-audio", + "intent": "chat", + }, + ) + + assert "?" not in captured["url"] + assert "model=" not in captured["url"] + assert "intent=" not in captured["url"] + + +def test_vertex_function_call_output_omits_id(): + """Regression: Vertex Live rejects ``id`` on toolResponse.functionResponses (1007).""" + cfg = VertexAIRealtimeConfig( + access_token="tok", project="my-proj", location="us-central1" + ) + cfg._tool_call_id_to_name["call_abc123"] = "terminate_call" + + messages = cfg.transform_realtime_request( + json.dumps( + { + "type": "conversation.item.create", + "item": { + "type": "function_call_output", + "call_id": "call_abc123", + "output": '{"status": "ok"}', + }, + } + ), + "gemini-live-2.5-flash-native-audio", + session_configuration_request="existing", + ) + + assert len(messages) == 1 + payload = json.loads(messages[0]) + function_response = payload["toolResponse"]["functionResponses"][0] + assert "id" not in function_response + assert function_response["name"] == "terminate_call" + assert function_response["response"] == {"status": "ok"}