mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
fix(websearch): deduplicate chat follow-up kwargs
This commit is contained in:
parent
5e4b3838aa
commit
7ce846da3d
2 changed files with 97 additions and 43 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue