fix(anthropic_adapter): keep reasoning_effort a string for targets that stay on chat completions (#42401)

* fix(anthropic_adapter): keep reasoning_effort a string for targets that stay on chat completions

* fix(anthropic_adapter): judge the summary bridge with the deployment's api_base

The adapter's bridge check now resolves the provider and base the same
way completion() does, passing the deployment's api_base and api_key
into get_llm_provider and the resolved base into the bridge check, so a
Foundry deployment lands on the same route in both places and a bare
model name routed by its api_base still gets the plain tier.

A litellm_proxy target keeps the dict, since the upstream gateway makes
its own bridge decision and needs the summary to make it.

* refactor(anthropic_adapter): return the plain effort instead of writing it inside the helper

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-21 21:08:28 -07:00 • committed by GitHub
parent 418561991b
commit 7956dd6e8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 173 additions and 0 deletions

View file

@ -27,6 +27,7 @@ from litellm.llms.anthropic.experimental_pass_through.utils import (
from litellm.types.llms.anthropic_messages.anthropic_response import (
AnthropicMessagesResponse,
)
from litellm.types.llms.openai import OpenAIWebSearchOptions
from litellm.types.utils import ModelResponse
from litellm.utils import get_model_info
@ -383,6 +384,49 @@ class LiteLLMMessagesToCompletionTransformationHandler:
updated_reasoning_effort["summary"] = effective_summary
completion_kwargs["reasoning_effort"] = updated_reasoning_effort
@staticmethod
def _plain_effort_for_chat_target(
completion_kwargs: _CompletionKwargs,
*,
thinking: Mapping[str, object] | None,
) -> str | None:
reasoning_effort: Final = completion_kwargs.get("reasoning_effort")
if not thinking or not isinstance(reasoning_effort, dict) or "summary" not in reasoning_effort:
return None
effort: Final = reasoning_effort.get("effort")
model: Final = completion_kwargs.get("model")
if not isinstance(effort, str) or not isinstance(model, str) or not model:
return None
custom_llm_provider: Final = completion_kwargs.get("custom_llm_provider")
api_base: Final = completion_kwargs.get("api_base")
api_key: Final = completion_kwargs.get("api_key")
try:
local_model, resolved_provider, _, resolved_api_base = litellm.utils.get_llm_provider(
model=model,
custom_llm_provider=custom_llm_provider if isinstance(custom_llm_provider, str) else None,
api_base=api_base if isinstance(api_base, str) else None,
api_key=api_key if isinstance(api_key, str) else None,
)
except Exception:
return None
if resolved_provider == "litellm_proxy":
return None
from litellm.main import responses_api_bridge_check
web_search_options: Final = completion_kwargs.get("web_search_options")
tools: Final = completion_kwargs.get("tools")
model_info, _ = responses_api_bridge_check(
model=local_model,
custom_llm_provider=resolved_provider,
web_search_options=(
cast(OpenAIWebSearchOptions, web_search_options) if isinstance(web_search_options, dict) else None
),
tools=cast("list[dict[str, object]]", tools) if isinstance(tools, list) else None,
reasoning_effort=reasoning_effort,
api_base=resolved_api_base,
)
return None if model_info.get("mode") == "responses" else effort
@staticmethod
def _normalize_reasoning_effort(
completion_kwargs: _CompletionKwargs,
@ -547,6 +591,13 @@ class LiteLLMMessagesToCompletionTransformationHandler:
thinking=thinking,
)
plain_effort: Final = LiteLLMMessagesToCompletionTransformationHandler._plain_effort_for_chat_target(
completion_kwargs,
thinking=thinking,
)
if plain_effort is not None:
completion_kwargs["reasoning_effort"] = plain_effort
return completion_kwargs, tool_name_mapping
@staticmethod

View file

@ -6,6 +6,8 @@ regression they guard is the one a caller sees: a tier the proxy advertises has
leaves the adapter, in the shape the target expects.
"""
from typing import Final
import pytest
from litellm.llms.anthropic.experimental_pass_through.adapters.handler import (
@ -36,6 +38,126 @@ def _reasoning_effort_sent(model: str, provider: str, reasoning_effort: object)
return completion_kwargs.get("reasoning_effort")
def _reasoning_effort_sent_for_thinking(
model: str,
provider: str | None,
thinking: dict[str, object],
*,
tools: list[dict[str, object]] | None = None,
api_base: str | None = None,
) -> object:
extra_kwargs: Final = {
key: value for key, value in (("custom_llm_provider", provider), ("api_base", api_base)) if value is not None
}
completion_kwargs, _ = LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs(
max_tokens=1024,
messages=MESSAGES,
model=model,
metadata=None,
stop_sequences=None,
stream=False,
system=None,
temperature=None,
thinking=thinking,
tool_choice=None,
tools=tools,
top_k=None,
top_p=None,
output_format=None,
extra_kwargs=extra_kwargs,
)
return completion_kwargs.get("reasoning_effort")
SUMMARIZED_THINKING = {"type": "enabled", "budget_tokens": 4096, "summary": "auto"}
PLAIN_THINKING = {"type": "enabled", "budget_tokens": 4096}
MULTIPLY_TOOL = {
"name": "multiply",
"description": "Multiply two integers",
"input_schema": {"type": "object", "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}},
}
class TestTheSummaryWrappingOnlyRidesTheResponsesBridge:
"""Only the Responses API takes ``reasoning_effort`` as a dict. Databricks answered the wrapped
``{"effort", "summary"}`` with ``field 'reasoning_effort' expects input with json type 'string'
but got 'object'``, so a target that stays on chat completions has to get the plain tier and a
target the bridge picks up has to keep the summary it can honor."""
@pytest.mark.parametrize(
"model, provider",
[
("databricks/databricks-qwen35-122b-a10b", "databricks"),
("databricks-qwen35-122b-a10b", "databricks"),
("fireworks_ai/kimi-k3", "fireworks_ai"),
],
)
def test_a_chat_target_gets_the_plain_tier(self, local_model_cost_map: None, model: str, provider: str) -> None:
assert _reasoning_effort_sent_for_thinking(model, provider, SUMMARIZED_THINKING) == "high"
def test_auto_summary_stays_a_plain_tier_on_a_chat_target(
self, local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true")
sent = _reasoning_effort_sent_for_thinking("databricks/databricks-qwen35-122b-a10b", "databricks", PLAIN_THINKING)
assert sent == "high"
@pytest.mark.parametrize(
"model, provider",
[
("azure/responses/gpt-5-mini", "azure"),
("gpt-5-mini", "openai"),
("databricks/databricks-gpt-5-5", "databricks"),
],
)
def test_a_bridged_target_keeps_the_summary(self, local_model_cost_map: None, model: str, provider: str) -> None:
sent = _reasoning_effort_sent_for_thinking(model, provider, SUMMARIZED_THINKING)
assert sent == {"effort": "high", "summary": "auto"}
def test_auto_summary_still_reaches_a_bridged_target(
self, local_model_cost_map: None, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_REASONING_AUTO_SUMMARY", "true")
sent = _reasoning_effort_sent_for_thinking("azure/responses/gpt-5-mini", "azure", PLAIN_THINKING)
assert sent == {"effort": "high", "summary": "detailed"}
@pytest.mark.parametrize(
"api_base, expected",
[
("https://foo.services.ai.azure.com/openai/v1", "high"),
("https://foo.eastus.models.ai.azure.com", {"effort": "high", "summary": "auto"}),
],
)
def test_a_foundry_deployment_is_judged_by_its_api_base(
self, local_model_cost_map: None, api_base: str, expected: object
) -> None:
"""``completion()`` keeps a gpt-5.5 deployment with function tools on Foundry's chat route when
its ``api_base`` is a Foundry OpenAI host, and bridges it to Responses when the base makes it an
Azure OpenAI deployment. The adapter has to read the same ``api_base`` to land on the same call."""
sent = _reasoning_effort_sent_for_thinking(
"azure_ai/gpt-5.5", "azure_ai", SUMMARIZED_THINKING, tools=[MULTIPLY_TOOL], api_base=api_base
)
assert sent == expected
def test_a_provider_resolved_from_the_api_base_gets_the_plain_tier(self, local_model_cost_map: None) -> None:
sent = _reasoning_effort_sent_for_thinking(
"kimi-k3", None, SUMMARIZED_THINKING, api_base="https://api.together.xyz/v1"
)
assert sent == "high"
def test_a_chained_gateway_keeps_the_dict_for_its_own_bridge(self, local_model_cost_map: None) -> None:
sent = _reasoning_effort_sent_for_thinking("litellm_proxy/gpt-5.4", "litellm_proxy", SUMMARIZED_THINKING)
assert sent == {"effort": "high", "summary": "auto"}
class TestTheNormalizedTierIsTheTierSent:
"""The bug in the caller's terms: a proxy advertising kimi-k3 ``max`` accepted the request and
then put ``high`` on the wire. Every spelling of the entry has to survive the adapter, including