This commit is contained in:
Mateo Wang 2026-11-06 05:04:35 +00:00 • committed by GitHub
commit e3acecb189
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 241 additions and 13 deletions

View file

@ -1068,12 +1068,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,
@ -1102,16 +1097,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,
@ -1334,6 +1356,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(

View file

@ -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

View file

@ -591,11 +591,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
@ -660,9 +664,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")

View file

@ -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)

View file

@ -5525,3 +5525,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

View file

@ -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,

View file

@ -1180,6 +1180,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)