From 7ce846da3d80288cdfcbac3ab2e461acefe64519 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 30 Aug 2026 17:14:50 +0800 Subject: [PATCH] fix(websearch): deduplicate chat follow-up kwargs --- litellm/llms/custom_httpx/llm_http_handler.py | 6 + .../custom_httpx/test_llm_http_handler.py | 134 ++++++++++++------ 2 files changed, 97 insertions(+), 43 deletions(-) diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 834f7d564a2..d7dcabf1c8d 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5538,6 +5538,12 @@ class BaseLLMHTTPHandler: and k not in internal_params } kwargs_for_followup.update(patch.kwargs) + # A request patch can carry provider connection parameters in kwargs + # even when the same values already live in optional_params. Keep the + # optional-params value as the single source of truth so the follow-up + # call cannot fail with duplicate keyword arguments. + for key in optional_params_for_followup: + kwargs_for_followup.pop(key, None) kwargs_for_followup["_agentic_loop_depth"] = depth + 1 kwargs_for_followup["max_agentic_loops"] = max_loops kwargs_for_followup["_agentic_loop_fingerprints"] = fingerprints + [fingerprint] diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 26f841c1146..dd4e99aed6b 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -10,9 +10,10 @@ import pytest import litellm from litellm._logging import verbose_logger from litellm.integrations.code_interpreter_interception.handler import ( - CodeInterpreterInterceptionLogger, LITELLM_CODE_EXECUTION_TOOL_NAME, + CodeInterpreterInterceptionLogger, ) +from litellm.llms.azure.videos.transformation import AzureVideoConfig from litellm.llms.base_llm.audio_transcription.transformation import ( AudioTranscriptionRequestData, BaseAudioTranscriptionConfig, @@ -26,8 +27,8 @@ from litellm.llms.custom_httpx.llm_http_handler import ( _has_pre_call_deployment_hook, _rust_responses_websocket_enabled, ) -from litellm.llms.azure.videos.transformation import AzureVideoConfig from litellm.llms.openai.videos.transformation import OpenAIVideoConfig +from litellm.types.integrations.custom_logger import AgenticLoopPlan, AgenticLoopRequestPatch from litellm.types.llms.openai import ResponsesAPIResponse from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import TranscriptionResponse @@ -36,6 +37,46 @@ _ACTIVE_KEY = "_code_interpreter_interception_active" _SANDBOX_KEY = "_code_interpreter_interception_sandbox_key" +@pytest.mark.asyncio +async def test_chat_completion_agentic_followup_deduplicates_optional_params(): + """Provider params must not be passed through both follow-up kwargs surfaces.""" + handler = BaseLLMHTTPHandler() + sentinel_response = object() + + async def fake_acompletion(**call_kwargs): + return sentinel_response + + plan = AgenticLoopPlan( + run_agentic_loop=True, + request_patch=AgenticLoopRequestPatch( + messages=[{"role": "user", "content": "search"}], + optional_params={"aws_region_name": "us-east-1"}, + kwargs={"aws_region_name": "us-east-1", "api_base": "https://example.invalid"}, + ), + ) + + with patch( # test-quality-ok: patch the SDK call boundary to assert follow-up kwargs without a provider request + "litellm.acompletion", side_effect=fake_acompletion + ) as mock_acompletion: + result = await handler._execute_chat_completion_agentic_plan( + plan=plan, + model="bedrock/anthropic.claude-sonnet-5", + messages=[{"role": "user", "content": "search"}], + optional_params={"aws_region_name": "us-east-1"}, + kwargs={"aws_region_name": "us-east-1"}, + custom_llm_provider="bedrock", + depth=0, + max_loops=3, + fingerprints=[], + fingerprint="fingerprint", + ) + + assert result is sentinel_response + call_kwargs = mock_acompletion.call_args.kwargs + assert call_kwargs["aws_region_name"] == "us-east-1" + assert call_kwargs["api_base"] == "https://example.invalid" + + def test_prepare_fake_stream_request(): # Initialize the BaseLLMHTTPHandler handler = BaseLLMHTTPHandler() @@ -1181,9 +1222,7 @@ def test_sync_delete_responses_sets_json_content_type(): ({}, True, None, None), ], ) -def test_resolve_anthropic_messages_timeout( - monkeypatch, litellm_params_kwargs, stream, global_timeout, expected -): +def test_resolve_anthropic_messages_timeout(monkeypatch, litellm_params_kwargs, stream, global_timeout, expected): from litellm.constants import DEFAULT_REQUEST_TIMEOUT_SECONDS if global_timeout is None: @@ -1199,9 +1238,7 @@ def test_resolve_anthropic_messages_timeout( ) else: monkeypatch.setattr("litellm.request_timeout", global_timeout, raising=False) - monkeypatch.setattr( - "litellm.request_timeout_explicitly_set", True, raising=False - ) + monkeypatch.setattr("litellm.request_timeout_explicitly_set", True, raising=False) resolved = BaseLLMHTTPHandler._resolve_anthropic_messages_timeout( litellm_params=GenericLiteLLMParams(**litellm_params_kwargs), @@ -1226,9 +1263,7 @@ async def test_async_anthropic_messages_handler_forwards_request_timeout(monkeyp return_value=({"x-api-key": "k"}, "https://api.anthropic.com") ) mock_config.should_filter_anthropic_beta_headers = Mock(return_value=False) - mock_config.transform_anthropic_messages_request = Mock( - return_value={"model": "claude", "messages": []} - ) + mock_config.transform_anthropic_messages_request = Mock(return_value={"model": "claude", "messages": []}) mock_config.get_complete_url = Mock(return_value="https://api.anthropic.com/v1/messages") mock_config.sign_request = Mock(return_value=({"x-api-key": "k"}, None)) mock_config.max_retry_on_anthropic_messages_http_error = 1 @@ -1274,9 +1309,7 @@ async def test_async_anthropic_messages_handler_forwards_stream_timeout(monkeypa return_value=({"x-api-key": "k"}, "https://api.anthropic.com") ) mock_config.should_filter_anthropic_beta_headers = Mock(return_value=False) - mock_config.transform_anthropic_messages_request = Mock( - return_value={"model": "claude", "messages": []} - ) + mock_config.transform_anthropic_messages_request = Mock(return_value={"model": "claude", "messages": []}) mock_config.get_complete_url = Mock(return_value="https://api.anthropic.com/v1/messages") mock_config.sign_request = Mock(return_value=({"x-api-key": "k"}, None)) mock_config.max_retry_on_anthropic_messages_http_error = 1 @@ -1686,7 +1719,13 @@ async def test_async_anthropic_messages_handler_passes_api_key_to_agentic_hooks( ) mock_config.sign_request = Mock(return_value=({}, None)) - fake_raw_response = {"id": "msg_1", "type": "message", "role": "assistant", "content": [], "stop_reason": "end_turn"} + fake_raw_response = { + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": [], + "stop_reason": "end_turn", + } mock_config.transform_anthropic_messages_response = Mock(return_value=fake_raw_response) mock_logging_obj = Mock() @@ -1706,10 +1745,17 @@ async def test_async_anthropic_messages_handler_passes_api_key_to_agentic_hooks( mock_httpx_response.status_code = 200 with ( - patch.object(handler, "_async_post_anthropic_messages_with_http_error_retry", new=AsyncMock(return_value=mock_httpx_response)), + patch.object( + handler, + "_async_post_anthropic_messages_with_http_error_retry", + new=AsyncMock(return_value=mock_httpx_response), + ), patch.object(handler, "_call_agentic_completion_hooks", side_effect=fake_agentic_hooks), patch("litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client"), - patch("litellm.litellm_core_utils.get_provider_specific_headers.ProviderSpecificHeaderUtils.get_provider_specific_headers", return_value=None), + patch( + "litellm.litellm_core_utils.get_provider_specific_headers.ProviderSpecificHeaderUtils.get_provider_specific_headers", + return_value=None, + ), ): result = await handler.async_anthropic_messages_handler( model="claude-haiku", @@ -1951,7 +1997,9 @@ def test_audio_transcriptions_sends_dict_data_as_json_body(): form-encodes it and silently ignores json=; JSON-body providers (e.g. Google Speech-to-Text) need an application/json body.""" captured = {} - client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_json_transcription_request(captured)))) + client = HTTPHandler( + client=httpx.Client(transport=httpx.MockTransport(_capture_json_transcription_request(captured))) + ) response = BaseLLMHTTPHandler().audio_transcriptions( client=client, @@ -2035,9 +2083,7 @@ def _transform_subtitle_response(payload): def test_subtitle_synthesis_fallback_without_timings_drops_words(): - response = _transform_subtitle_response( - {"text": "hello world", "words": [{"word": "hello"}, {"word": "world"}]} - ) + response = _transform_subtitle_response({"text": "hello world", "words": [{"word": "hello"}, {"word": "world"}]}) assert response.text == "hello world" assert "words" not in response @@ -2257,9 +2303,7 @@ async def test_anthropic_invalid_thinking_signature_retry_resigns_bedrock_reques ok_response = httpx.Response(200, json={"id": "msg_1"}, request=httpx.Request("POST", request_url)) class FakeAsyncClient: - async def post( - self, url, headers, data, stream=False, logging_obj=None, timeout=None - ): + async def post(self, url, headers, data, stream=False, logging_obj=None, timeout=None): posts.append({"headers": dict(headers), "data": data}) return invalid_signature_response if len(posts) == 1 else ok_response @@ -2803,7 +2847,13 @@ def _capture_video_create_request(captured): captured["body"] = request.content return httpx.Response( 200, - json={"id": "video_123", "object": "video", "status": "queued", "created_at": 1712697600, "model": "sora-2"}, + json={ + "id": "video_123", + "object": "video", + "status": "queued", + "created_at": 1712697600, + "model": "sora-2", + }, ) return respond @@ -2826,7 +2876,9 @@ def test_video_generation_without_file_sends_multipart_form_data(): captured = {} client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured)))) - result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(OpenAIVideoConfig())) + result = BaseLLMHTTPHandler().video_generation_handler( + client=client, **_video_create_call_kwargs(OpenAIVideoConfig()) + ) assert captured["content_type"].startswith("multipart/form-data") assert _multipart_text_fields(captured["content_type"], captured["body"]) == { @@ -2866,7 +2918,9 @@ def test_azure_video_generation_without_file_sends_multipart_form_data(): captured = {} client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured)))) - result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(AzureVideoConfig())) + result = BaseLLMHTTPHandler().video_generation_handler( + client=client, **_video_create_call_kwargs(AzureVideoConfig()) + ) assert captured["content_type"].startswith("multipart/form-data") assert _multipart_text_fields(captured["content_type"], captured["body"]) == { @@ -2881,7 +2935,9 @@ def test_video_generation_json_provider_keeps_json_body(): captured = {} client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured)))) - result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(_JSONBodyVideoConfig())) + result = BaseLLMHTTPHandler().video_generation_handler( + client=client, **_video_create_call_kwargs(_JSONBodyVideoConfig()) + ) assert captured["content_type"] == "application/json" assert json.loads(captured["body"]) == {"model": "sora-2", "prompt": "a cat surfing", "seconds": "4"} @@ -2911,6 +2967,7 @@ def test_video_generation_with_input_reference_keeps_file_multipart(): AZURE_AI_BASE = "https://myfoundry.services.ai.azure.com" AZURE_AI_CHAT_COMPLETIONS_URL = f"{AZURE_AI_BASE}/models/chat/completions" + def _a_tool_with_an_unsupported_field() -> dict: return { "type": "function", @@ -2918,14 +2975,13 @@ def _a_tool_with_an_unsupported_field() -> dict: "strict": True, } + A_COMPLETION = { "id": "chatcmpl-1", "object": "chat.completion", "created": 1, "model": "grok-3", - "choices": [ - {"index": 0, "message": {"role": "assistant", "content": "sent"}, "finish_reason": "stop"} - ], + "choices": [{"index": 0, "message": {"role": "assistant", "content": "sent"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, } @@ -2969,9 +3025,7 @@ def _call_azure_ai(recorder: _RecordedAzureAI, **overrides): def test_a_tool_field_the_provider_rejects_is_dropped_and_the_call_retried(): - recorder = _RecordedAzureAI( - [_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)] - ) + recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]) response = _call_azure_ai(recorder) @@ -2982,9 +3036,7 @@ def test_a_tool_field_the_provider_rejects_is_dropped_and_the_call_retried(): def test_the_retry_changes_only_the_field_the_provider_named(): - recorder = _RecordedAzureAI( - [_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)] - ) + recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]) _call_azure_ai(recorder) @@ -3023,9 +3075,7 @@ def test_an_extra_input_outside_a_tool_is_not_retried_unless_dropping_params_was def test_an_extra_input_outside_a_tool_is_retried_when_dropping_params_was_asked_for(): - recorder = _RecordedAzureAI( - [_rejection(UNRELATED_REJECTION), httpx.Response(200, json=A_COMPLETION)] - ) + recorder = _RecordedAzureAI([_rejection(UNRELATED_REJECTION), httpx.Response(200, json=A_COMPLETION)]) response = _call_azure_ai(recorder, drop_params=True) @@ -3039,9 +3089,7 @@ async def test_a_tool_field_the_provider_rejects_is_dropped_and_retried_on_the_a ): import respx - recorder = _RecordedAzureAI( - [_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)] - ) + recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)]) with respx.mock(assert_all_called=True) as router: router.post(AZURE_AI_CHAT_COMPLETIONS_URL).mock(side_effect=recorder)