mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 34353d15d4 into aeede53112
This commit is contained in:
commit
7cd54bee11
8 changed files with 241 additions and 13 deletions
|
|
@ -1116,12 +1116,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
unknown backend, because that provider declares this param and forwards it to a proxy
|
||||
that resolves the real target itself, where a derived cache key has no such guarantee.
|
||||
"""
|
||||
if not model or not custom_llm_provider:
|
||||
return False
|
||||
supported_params: Final = litellm.get_supported_openai_params(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
return "reasoning_effort" in (supported_params or ())
|
||||
return LiteLLMAnthropicMessagesAdapter._target_declares("reasoning_effort", model, custom_llm_provider)
|
||||
|
||||
def _translate_metadata_to_openai(
|
||||
self,
|
||||
|
|
@ -1150,16 +1145,43 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
self,
|
||||
anthropic_message_request: AnthropicMessagesRequest,
|
||||
new_kwargs: ChatCompletionRequest,
|
||||
custom_llm_provider: str | None = None,
|
||||
) -> None:
|
||||
"""Translate Anthropic tool_choice to OpenAI format."""
|
||||
"""Translate Anthropic tool_choice to OpenAI format.
|
||||
|
||||
``disable_parallel_tool_use`` becomes ``parallel_tool_calls=False`` only for a target that
|
||||
declares the param, so a provider without it keeps answering instead of rejecting the
|
||||
request the way it already does an OpenAI-shaped ``parallel_tool_calls``.
|
||||
"""
|
||||
if "tool_choice" not in anthropic_message_request:
|
||||
return
|
||||
tool_choice: Final = anthropic_message_request["tool_choice"]
|
||||
if not tool_choice:
|
||||
return
|
||||
new_kwargs["tool_choice"] = self.translate_anthropic_tool_choice_to_openai(
|
||||
tool_choice=cast(AnthropicMessagesToolChoice, tool_choice)
|
||||
anthropic_tool_choice: Final = cast(AnthropicMessagesToolChoice, tool_choice)
|
||||
new_kwargs["tool_choice"] = self.translate_anthropic_tool_choice_to_openai(tool_choice=anthropic_tool_choice)
|
||||
if anthropic_tool_choice.get("disable_parallel_tool_use") is True and self._target_declares_parallel_tool_calls(
|
||||
anthropic_message_request["model"], custom_llm_provider
|
||||
):
|
||||
new_kwargs["parallel_tool_calls"] = False # rebind-ok: out-param store like tool_choice above
|
||||
|
||||
@staticmethod
|
||||
def _target_declares_parallel_tool_calls(model: str | None, custom_llm_provider: str | None) -> bool:
|
||||
"""Whether the target declares ``parallel_tool_calls`` among its supported params.
|
||||
|
||||
Without a resolved provider the flag stays behind, which is what this bridge sent before
|
||||
it carried one at all, for the same reasons as ``_target_declares_reasoning_effort``.
|
||||
"""
|
||||
return LiteLLMAnthropicMessagesAdapter._target_declares("parallel_tool_calls", model, custom_llm_provider)
|
||||
|
||||
@staticmethod
|
||||
def _target_declares(param: str, model: str | None, custom_llm_provider: str | None) -> bool:
|
||||
if not model or not custom_llm_provider:
|
||||
return False
|
||||
supported_params: Final = litellm.get_supported_openai_params(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
return param in (supported_params or ())
|
||||
|
||||
def _translate_stop_sequences_to_openai(
|
||||
self,
|
||||
|
|
@ -1384,6 +1406,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
self._translate_tool_choice_to_openai(
|
||||
anthropic_message_request=anthropic_message_request,
|
||||
new_kwargs=new_kwargs,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
## CONVERT TOOLS
|
||||
tool_name_mapping = self._translate_tools_to_openai(
|
||||
|
|
|
|||
|
|
@ -173,12 +173,20 @@ def _forwarded_kwargs(extra_kwargs: Mapping[str, object] | None) -> Mapping[str,
|
|||
|
||||
|
||||
def _provider_returns_encrypted_reasoning(model: str, custom_llm_provider: object) -> bool:
|
||||
return _responses_config_declares("include", model, custom_llm_provider)
|
||||
|
||||
|
||||
def _provider_declares_parallel_tool_calls(model: str, custom_llm_provider: object) -> bool:
|
||||
return _responses_config_declares("parallel_tool_calls", model, custom_llm_provider)
|
||||
|
||||
|
||||
def _responses_config_declares(param: str, model: str, custom_llm_provider: object) -> bool:
|
||||
provider: Final = (
|
||||
custom_llm_provider if isinstance(custom_llm_provider, str) else litellm.get_llm_provider(model=model)[1]
|
||||
)
|
||||
provider_model: Final = local_model_name(model, provider)
|
||||
responses_config: Final = ProviderConfigManager.get_provider_responses_api_config(provider, provider_model)
|
||||
return responses_config is not None and "include" in responses_config.get_supported_openai_params(provider_model)
|
||||
return responses_config is not None and param in responses_config.get_supported_openai_params(provider_model)
|
||||
|
||||
|
||||
def _reads_prompt_cache_breakpoint(model: str, forwarded_kwargs: Mapping[str, object]) -> bool:
|
||||
|
|
@ -333,6 +341,9 @@ def _build_responses_kwargs(
|
|||
),
|
||||
auto_summary_level=auto_reasoning_summary(model, forwarded_kwargs.get("custom_llm_provider")),
|
||||
drop_params=drops_unsupported_params(forwarded_kwargs.get("drop_params")),
|
||||
declares_parallel_tool_calls=_provider_declares_parallel_tool_calls(
|
||||
model, forwarded_kwargs.get("custom_llm_provider")
|
||||
),
|
||||
)
|
||||
|
||||
# Normalize reasoning effort based on model capabilities
|
||||
|
|
|
|||
|
|
@ -597,11 +597,15 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
auto_summary_level: str = "detailed",
|
||||
*,
|
||||
drop_params: bool = True,
|
||||
declares_parallel_tool_calls: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Translate a full Anthropic /v1/messages request dict to
|
||||
litellm.responses() / litellm.aresponses() kwargs.
|
||||
|
||||
``declares_parallel_tool_calls`` says whether the provider's Responses API accepts
|
||||
``parallel_tool_calls``; ``tool_choice.disable_parallel_tool_use`` maps onto it only then.
|
||||
|
||||
``include_encrypted_reasoning`` asks the provider for ``reasoning.encrypted_content``
|
||||
on every call, so a reasoning model's items can be replayed intact next turn even
|
||||
when the client sent no ``thinking`` block; pass False for a provider whose
|
||||
|
|
@ -666,9 +670,10 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
# tool_choice
|
||||
tool_choice: Final = anthropic_request.get("tool_choice")
|
||||
if tool_choice:
|
||||
responses_kwargs["tool_choice"] = self.translate_tool_choice_to_responses_api(
|
||||
cast(AnthropicMessagesToolChoice, tool_choice)
|
||||
)
|
||||
anthropic_tool_choice: Final = cast(AnthropicMessagesToolChoice, tool_choice)
|
||||
responses_kwargs["tool_choice"] = self.translate_tool_choice_to_responses_api(anthropic_tool_choice)
|
||||
if anthropic_tool_choice.get("disable_parallel_tool_use") is True and declares_parallel_tool_calls:
|
||||
responses_kwargs["parallel_tool_calls"] = False
|
||||
|
||||
# thinking -> reasoning
|
||||
thinking: Final = anthropic_request.get("thinking")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
from dataclasses import replace
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support.client import Gateway
|
||||
from integration._support.provider import SharedProvider
|
||||
from integration.translation.case import TranslationTestCase
|
||||
from integration.translation.messages.bases.azure import GPT_6_LUNA_TEST_CASE as AZURE_GPT_6_LUNA_TEST_CASE
|
||||
from integration.translation.messages.bases.bedrock_converse import CLAUDE_SONNET_4_6_TEST_CASE
|
||||
from integration.translation.messages.bases.openai import GPT_5_4_TEST_CASE as OPENAI_GPT_5_4_TEST_CASE
|
||||
from integration.translation.runner import assert_translation
|
||||
from pydantic import JsonValue
|
||||
|
||||
LOOKUP_SCHEMA: Final[dict[str, JsonValue]] = {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
}
|
||||
ANTHROPIC_TOOLS: Final[list[JsonValue]] = [
|
||||
{"name": "lookup_weather", "description": "Look up the weather for a city.", "input_schema": LOOKUP_SCHEMA}
|
||||
]
|
||||
RESPONSES_TOOLS: Final[list[JsonValue]] = [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup_weather",
|
||||
"strict": False,
|
||||
"description": "Look up the weather for a city.",
|
||||
"parameters": LOOKUP_SCHEMA,
|
||||
}
|
||||
]
|
||||
BEDROCK_TOOL_CONFIG: Final[dict[str, JsonValue]] = {
|
||||
"tools": [
|
||||
{
|
||||
"toolSpec": {
|
||||
"name": "lookup_weather",
|
||||
"description": "Look up the weather for a city.",
|
||||
"inputSchema": {"json": LOOKUP_SCHEMA},
|
||||
}
|
||||
}
|
||||
],
|
||||
"toolChoice": {"auto": {}},
|
||||
}
|
||||
DISABLE_PARALLEL_TOOL_USE: Final[dict[str, JsonValue]] = {"type": "auto", "disable_parallel_tool_use": True}
|
||||
SCENARIO: Final = "tool_choice_disable_parallel_tool_use"
|
||||
|
||||
OPENAI_GPT_5_4_DISABLE_PARALLEL_TEST_CASE: Final = replace(
|
||||
OPENAI_GPT_5_4_TEST_CASE,
|
||||
scenario=SCENARIO,
|
||||
litellm_request={
|
||||
**OPENAI_GPT_5_4_TEST_CASE.litellm_request,
|
||||
"tools": ANTHROPIC_TOOLS,
|
||||
"tool_choice": DISABLE_PARALLEL_TOOL_USE,
|
||||
},
|
||||
expected_provider_request={
|
||||
**OPENAI_GPT_5_4_TEST_CASE.expected_provider_request,
|
||||
"tools": RESPONSES_TOOLS,
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": False,
|
||||
},
|
||||
)
|
||||
|
||||
AZURE_GPT_6_LUNA_DISABLE_PARALLEL_TEST_CASE: Final = replace(
|
||||
AZURE_GPT_6_LUNA_TEST_CASE,
|
||||
scenario=SCENARIO,
|
||||
litellm_request={**AZURE_GPT_6_LUNA_TEST_CASE.litellm_request, "tool_choice": DISABLE_PARALLEL_TOOL_USE},
|
||||
expected_provider_request={
|
||||
**AZURE_GPT_6_LUNA_TEST_CASE.expected_provider_request,
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": False,
|
||||
},
|
||||
)
|
||||
|
||||
CLAUDE_SONNET_4_6_DISABLE_PARALLEL_TEST_CASE: Final = replace(
|
||||
CLAUDE_SONNET_4_6_TEST_CASE,
|
||||
scenario=SCENARIO,
|
||||
litellm_request={
|
||||
**CLAUDE_SONNET_4_6_TEST_CASE.litellm_request,
|
||||
"tools": ANTHROPIC_TOOLS,
|
||||
"tool_choice": DISABLE_PARALLEL_TOOL_USE,
|
||||
},
|
||||
expected_provider_request={
|
||||
**CLAUDE_SONNET_4_6_TEST_CASE.expected_provider_request,
|
||||
"toolConfig": BEDROCK_TOOL_CONFIG,
|
||||
"additionalModelRequestFields": {"tool_choice": {"disable_parallel_tool_use": True}},
|
||||
},
|
||||
)
|
||||
|
||||
CASES: Final = (
|
||||
OPENAI_GPT_5_4_DISABLE_PARALLEL_TEST_CASE,
|
||||
AZURE_GPT_6_LUNA_DISABLE_PARALLEL_TEST_CASE,
|
||||
CLAUDE_SONNET_4_6_DISABLE_PARALLEL_TEST_CASE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", CASES, ids=lambda case: case.id)
|
||||
def test_messages_params_tool_choice(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None:
|
||||
assert_translation(case, gateway, provider)
|
||||
|
|
@ -5578,3 +5578,47 @@ async def test_chat_bridge_tool_result_text_block_without_text_reaches_the_model
|
|||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_choice", "model", "custom_llm_provider", "expected_parallel_tool_calls"),
|
||||
[
|
||||
({"type": "auto", "disable_parallel_tool_use": True}, "gpt-5.4", "openai", False),
|
||||
({"type": "any", "disable_parallel_tool_use": True}, "gpt-5.4", "openai", False),
|
||||
({"type": "auto", "disable_parallel_tool_use": True}, "openai/gpt-oss-120b", "groq", False),
|
||||
({"type": "auto", "disable_parallel_tool_use": False}, "gpt-5.4", "openai", None),
|
||||
({"type": "auto"}, "gpt-5.4", "openai", None),
|
||||
({"type": "auto", "disable_parallel_tool_use": True}, "command-a-03-2025", "cohere_chat", None),
|
||||
({"type": "auto", "disable_parallel_tool_use": True}, "gpt-5.4", None, None),
|
||||
],
|
||||
ids=[
|
||||
"openai-auto-disabled",
|
||||
"openai-any-disabled",
|
||||
"groq-auto-disabled",
|
||||
"openai-auto-enabled",
|
||||
"openai-auto-omitted",
|
||||
"cohere-declares-no-parallel-tool-calls-stays-behind",
|
||||
"no-provider-stays-behind",
|
||||
],
|
||||
)
|
||||
def test_translate_anthropic_to_openai_disable_parallel_tool_use_sets_parallel_tool_calls(
|
||||
tool_choice: dict[str, object],
|
||||
model: str,
|
||||
custom_llm_provider: str | None,
|
||||
expected_parallel_tool_calls: bool | None,
|
||||
) -> None:
|
||||
from litellm.types.llms.anthropic import AnthropicMessagesRequest
|
||||
|
||||
request: Final = AnthropicMessagesRequest(
|
||||
model=model,
|
||||
max_tokens=64,
|
||||
messages=[{"role": "user", "content": "What is the weather in Paris?"}],
|
||||
tools=[{"name": "lookup_weather", "input_schema": {"type": "object", "properties": {}}}],
|
||||
tool_choice=tool_choice,
|
||||
)
|
||||
|
||||
translated, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
|
||||
request, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
|
||||
assert translated.get("parallel_tool_calls") == expected_parallel_tool_calls
|
||||
|
|
|
|||
|
|
@ -52,6 +52,30 @@ def test_build_responses_kwargs_never_asks_the_nested_call_to_replay_a_stream():
|
|||
assert responses_kwargs["custom_llm_provider"] == "azure"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "custom_llm_provider", "expected_parallel_tool_calls"),
|
||||
[
|
||||
("openai/gpt-5.6-luna", "openai", False),
|
||||
("azure/gpt-5.6-luna", "azure", False),
|
||||
("perplexity/sonar", "perplexity", None),
|
||||
],
|
||||
ids=["openai-declares", "azure-declares", "perplexity-declares-no-parallel-tool-calls-stays-behind"],
|
||||
)
|
||||
def test_build_responses_kwargs_maps_disable_parallel_tool_use_only_where_the_provider_declares_it(
|
||||
model: str, custom_llm_provider: str, expected_parallel_tool_calls: bool | None
|
||||
):
|
||||
responses_kwargs = _build_responses_kwargs(
|
||||
max_tokens=1024,
|
||||
messages=MESSAGES,
|
||||
model=model,
|
||||
tools=[{"name": "do_thing", "input_schema": {"type": "object", "properties": {}}}],
|
||||
tool_choice={"type": "auto", "disable_parallel_tool_use": True},
|
||||
extra_kwargs={"custom_llm_provider": custom_llm_provider},
|
||||
)
|
||||
assert responses_kwargs.get("parallel_tool_calls") == expected_parallel_tool_calls
|
||||
assert responses_kwargs["tool_choice"] == "auto"
|
||||
|
||||
|
||||
def test_build_responses_kwargs_derives_prompt_cache_key_from_claude_code_session_id():
|
||||
responses_kwargs = _build_responses_kwargs(
|
||||
max_tokens=1024,
|
||||
|
|
|
|||
|
|
@ -1203,6 +1203,30 @@ class TestTranslateRequestBroaderCoverage:
|
|||
kwargs = _ADAPTER.translate_request(req)
|
||||
assert kwargs["tool_choice"] == {"type": "function", "name": "do_thing"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_choice", "expected_parallel_tool_calls"),
|
||||
[
|
||||
({"type": "auto", "disable_parallel_tool_use": True}, False),
|
||||
({"type": "any", "disable_parallel_tool_use": True}, False),
|
||||
({"type": "auto", "disable_parallel_tool_use": False}, None),
|
||||
({"type": "auto"}, None),
|
||||
],
|
||||
)
|
||||
def test_disable_parallel_tool_use_sets_parallel_tool_calls(
|
||||
self, tool_choice: dict[str, object], expected_parallel_tool_calls: bool | None
|
||||
):
|
||||
req = _make_request(tools=[{"name": "do_thing"}], tool_choice=tool_choice)
|
||||
kwargs = _ADAPTER.translate_request(req)
|
||||
assert kwargs.get("parallel_tool_calls") == expected_parallel_tool_calls
|
||||
|
||||
def test_disable_parallel_tool_use_stays_behind_when_provider_lacks_parallel_tool_calls(self):
|
||||
req = _make_request(
|
||||
tools=[{"name": "do_thing"}], tool_choice={"type": "auto", "disable_parallel_tool_use": True}
|
||||
)
|
||||
kwargs = _ADAPTER.translate_request(req, declares_parallel_tool_calls=False)
|
||||
assert "parallel_tool_calls" not in kwargs
|
||||
assert kwargs["tool_choice"] == "auto"
|
||||
|
||||
def test_thinking_translated_to_reasoning(self):
|
||||
req = _make_request(thinking={"type": "enabled", "budget_tokens": 12000})
|
||||
kwargs = _ADAPTER.translate_request(req)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue