This commit is contained in:
Nate Armstrong 2026-10-20 01:09:20 +00:00 • committed by GitHub
commit d445aeae07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 321 additions and 152 deletions

View file

@ -414,6 +414,7 @@ jobs:
tests/unit/images
tests/unit/interactions
tests/unit/messages
tests/unit/native
tests/unit/ocr
tests/unit/passthrough
tests/unit/rag

View file

@ -28,14 +28,15 @@ from litellm.llms.base_llm.anthropic_messages.transformation import (
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.native.route_plan import NativeChat, NativeResponses, native_messages_config, plan_messages_route
from litellm.types.llms.anthropic import AllAnthropicToolsValues
from litellm.types.llms.anthropic_messages.anthropic_request import AnthropicMetadata
from litellm.types.llms.anthropic_messages.anthropic_response import (
AnthropicMessagesResponse,
)
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import CallTypes, LlmProviders
from litellm.utils import ProviderConfigManager, client
from litellm.types.utils import CallTypes
from litellm.utils import client
from ..adapters.handler import LiteLLMMessagesToCompletionTransformationHandler, request_safeguards_evaluator
from ..responses_adapters.handler import LiteLLMMessagesToResponsesAPIHandler
@ -50,62 +51,10 @@ from .utils import AnthropicMessagesRequestUtils, mock_response, prepare_native_
__all__ = ("anthropic_messages", "anthropic_messages_handler")
# Providers that are routed directly to the OpenAI Responses API instead of
# going through chat/completions.
_RESPONSES_API_PROVIDERS: Final = frozenset({"openai"})
_SAFEGUARD_KWARGS: Final = TypeAdapter(Mapping[str, object])
_SAFEGUARD_MESSAGES: Final = TypeAdapter(Sequence[Mapping[str, object]])
def _bridges_to_responses_api(model: str, custom_llm_provider: str, deployment_model_info: object) -> bool:
from litellm.responses.bridge_check import responses_api_bridge_check, str_keyed_mapping
model_info, _ = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
deployment_model_info=str_keyed_mapping(deployment_model_info),
)
return model_info.get("mode") == "responses"
def _responses_mode_is_lost_by_prefix_strip(
requested_model: str, resolved_model: str, custom_llm_provider: str, deployment_model_info: object
) -> bool:
"""Whether a Responses-only deployment stops looking like one once its provider prefix is stripped.
``litellm.completion`` re-derives the Responses bridge from the stripped id alone, so a
deployment id such as ``perplexity/perplexity/sonar`` (mode ``responses``) is shadowed by the
chat entry ``perplexity/sonar`` and would otherwise be sent to chat/completions.
"""
if requested_model == resolved_model:
return False
return _bridges_to_responses_api(
requested_model, custom_llm_provider, deployment_model_info
) and not _bridges_to_responses_api(resolved_model, custom_llm_provider, deployment_model_info)
def _should_route_to_responses_api(
custom_llm_provider: str | None,
requested_model: str | None = None,
resolved_model: str | None = None,
deployment_model_info: object = None,
) -> bool:
"""Return True when the request should use the Responses API path.
Set ``litellm.use_chat_completions_url_for_anthropic_messages = True`` to
opt out and route OpenAI/Azure requests through chat/completions instead.
"""
if litellm.use_chat_completions_url_for_anthropic_messages:
return False
if custom_llm_provider in _RESPONSES_API_PROVIDERS:
return True
if custom_llm_provider is None or requested_model is None or resolved_model is None:
return False
return _responses_mode_is_lost_by_prefix_strip(
requested_model, resolved_model, custom_llm_provider, deployment_model_info
)
async def _with_native_safeguard_results(
pending: Coroutine[object, object, AnthropicMessagesResponse | AsyncIterator[bytes]],
proxy_classifies: Callable[[], Awaitable[bool]],
@ -133,46 +82,6 @@ def _native_safeguards_evaluator(kwargs: Mapping[str, object], messages: object)
return request_safeguards_evaluator(kwargs, _SAFEGUARD_MESSAGES.validate_python(messages))
def _deployment_passes_through_anthropic_messages(model_info: object) -> bool:
"""Whether the deployment opted into forwarding /v1/messages untranslated.
The opt-in is ``model_info.supported_endpoints`` containing ``"/v1/messages"``,
declared per deployment in config.yaml and plumbed here as ``kwargs["model_info"]``
by the router.
"""
if not isinstance(model_info, dict):
return False
supported_endpoints: Final = model_info.get("supported_endpoints")
return isinstance(supported_endpoints, (list, tuple)) and "/v1/messages" in supported_endpoints
def _deployment_supports_cache_control_ttl(model_info: object) -> bool:
return isinstance(model_info, dict) and model_info.get("cache_control_ttl") is True
def _native_messages_config(
model: str, custom_llm_provider: str | None, model_info: object, api_base: str | None
) -> BaseAnthropicMessagesConfig | None:
provider_config: Final = (
ProviderConfigManager.get_provider_anthropic_messages_config(
model=model,
provider=litellm.LlmProviders(custom_llm_provider),
)
if custom_llm_provider is not None and custom_llm_provider in [provider.value for provider in LlmProviders]
else None
)
native_config: Final = (
provider_config if provider_config is not None and provider_config.serves_api_base(model, api_base) else None
)
if native_config is not None or not _deployment_passes_through_anthropic_messages(model_info):
return native_config
from litellm.llms.openai_like.messages.transformation import (
OpenAILikeAnthropicMessagesConfig,
)
return OpenAILikeAnthropicMessagesConfig(cache_control_ttl=_deployment_supports_cache_control_ttl(model_info))
async def _proxy_classifies(model: str, safeguards: object, native_config: BaseAnthropicMessagesConfig | None) -> bool:
if not proxy_serves_safeguards(safeguards):
return False
@ -189,7 +98,7 @@ async def proxy_classifies_tool_uses(model: str, custom_llm_provider: str | None
return await _proxy_classifies(
model,
kwargs.get("safeguards"),
_native_messages_config(
native_messages_config(
model, custom_llm_provider, kwargs.get("model_info"), api_base if isinstance(api_base, str) else None
),
)
@ -636,40 +545,43 @@ def anthropic_messages_handler(
**kwargs,
)
anthropic_messages_provider_config: Final = _native_messages_config(
model, custom_llm_provider, kwargs.get("model_info"), api_base
route: Final = plan_messages_route(
requested_model=original_model,
resolved_model=model,
custom_llm_provider=custom_llm_provider,
model_info=kwargs.get("model_info"),
api_base=api_base,
compaction_requested=kwargs.get("compaction") is not None,
force_chat_completions=bool(litellm.use_chat_completions_url_for_anthropic_messages),
)
if anthropic_messages_provider_config is None:
# Route to Responses API for OpenAI / Azure, chat/completions for everything else.
if kwargs.get("compaction") is None and _should_route_to_responses_api(
custom_llm_provider, original_model, model, kwargs.get("model_info")
):
return LiteLLMMessagesToResponsesAPIHandler.anthropic_messages_handler(
max_tokens=max_tokens,
messages=messages,
model=original_model,
metadata=metadata,
stop_sequences=stop_sequences,
stream=stream,
system=cast( # cast-ok: anthropic accepts list system blocks; handler signature is narrower
"str | None", system
),
temperature=temperature,
thinking=thinking,
tool_choice=tool_choice,
tools=cast( # cast-ok: dict tools already fit the handler's union
"list[AllAnthropicToolsValues | dict[str, object]] | None", tools
),
top_k=top_k,
top_p=top_p,
_is_async=is_async,
api_key=api_key,
api_base=api_base,
client=client,
custom_llm_provider=custom_llm_provider,
**kwargs,
)
if isinstance(route, NativeResponses):
return LiteLLMMessagesToResponsesAPIHandler.anthropic_messages_handler(
max_tokens=max_tokens,
messages=messages,
model=original_model,
metadata=metadata,
stop_sequences=stop_sequences,
stream=stream,
system=cast( # cast-ok: anthropic accepts list system blocks; handler signature is narrower
"str | None", system
),
temperature=temperature,
thinking=thinking,
tool_choice=tool_choice,
tools=cast( # cast-ok: dict tools already fit the handler's union
"list[AllAnthropicToolsValues | dict[str, object]] | None", tools
),
top_k=top_k,
top_p=top_p,
_is_async=is_async,
api_key=api_key,
api_base=api_base,
client=client,
custom_llm_provider=custom_llm_provider,
**kwargs,
)
if isinstance(route, NativeChat):
# The in-gateway context_management polyfill runs inside
# ``async_anthropic_messages_handler`` so it can ``await`` the
# summarization model for ``compact_20260112``. ``context_management``
@ -698,6 +610,7 @@ def anthropic_messages_handler(
**kwargs,
)
anthropic_messages_provider_config: Final = route.config
if custom_llm_provider is None:
raise ValueError(
f"custom_llm_provider is required for Anthropic messages, passed in model={model}, custom_llm_provider={custom_llm_provider}"

View file

@ -134,6 +134,7 @@ from litellm.llms.vertex_ai.common_utils import (
VertexAIModelRoute,
get_vertex_ai_model_route,
)
from litellm.native.route_plan import NativeResponses, plan_chat_route
from litellm.realtime_api.main import realtime_health_check
from litellm.secret_managers.main import get_secret_bool, get_secret_str
from litellm.types.completion import (
@ -6026,7 +6027,10 @@ def completion(
# detection when the deployment name differs from the model name.
_azure_detection_model: Final = base_model or model
if responses_api_model_info.get("mode") == "responses" and not skip_responses_api_bridge:
if isinstance(
plan_chat_route(responses_api_model_info.get("mode") == "responses", skip_responses_api_bridge is True),
NativeResponses,
):
from litellm.completion_extras import responses_api_bridge
optional_params, rs_val = strip_reasoning_summary_aliases_from_optional_params(optional_params)

View file

@ -0,0 +1,162 @@
"""Which native API format a request reaches its provider in, decided once per request by the client-facing surface."""
from dataclasses import dataclass
from typing import Final, TypeAlias
from litellm.llms.base_llm.anthropic_messages.transformation import BaseAnthropicMessagesConfig
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
__all__ = (
"NativeChat",
"NativeMessages",
"NativeResponses",
"RoutePlan",
"native_messages_config",
"plan_chat_route",
"plan_messages_route",
"plan_responses_route",
)
_MESSAGES_TO_RESPONSES_PROVIDERS: Final = frozenset({"openai"})
_PROVIDER_VALUES: Final = frozenset(provider.value for provider in LlmProviders)
@dataclass(frozen=True, slots=True)
class NativeMessages:
config: BaseAnthropicMessagesConfig
@dataclass(frozen=True, slots=True)
class NativeResponses:
pass
@dataclass(frozen=True, slots=True)
class NativeChat:
pass
RoutePlan: TypeAlias = NativeMessages | NativeResponses | NativeChat
def _deployment_passes_through_anthropic_messages(model_info: object) -> bool:
"""Whether the deployment opted into forwarding /v1/messages untranslated.
The opt-in is ``model_info.supported_endpoints`` containing ``"/v1/messages"``,
declared per deployment in config.yaml and plumbed here as ``kwargs["model_info"]``
by the router.
"""
if not isinstance(model_info, dict):
return False
supported_endpoints: Final = model_info.get("supported_endpoints")
return isinstance(supported_endpoints, (list, tuple)) and "/v1/messages" in supported_endpoints
def _deployment_supports_cache_control_ttl(model_info: object) -> bool:
return isinstance(model_info, dict) and model_info.get("cache_control_ttl") is True
def native_messages_config(
model: str, custom_llm_provider: str | None, model_info: object, api_base: str | None
) -> BaseAnthropicMessagesConfig | None:
provider_config: Final = (
ProviderConfigManager.get_provider_anthropic_messages_config(
model=model,
provider=LlmProviders(custom_llm_provider),
)
if custom_llm_provider is not None and custom_llm_provider in _PROVIDER_VALUES
else None
)
native_config: Final = (
provider_config if provider_config is not None and provider_config.serves_api_base(model, api_base) else None
)
if native_config is not None or not _deployment_passes_through_anthropic_messages(model_info):
return native_config
from litellm.llms.openai_like.messages.transformation import (
OpenAILikeAnthropicMessagesConfig,
)
return OpenAILikeAnthropicMessagesConfig(cache_control_ttl=_deployment_supports_cache_control_ttl(model_info))
def _bridges_to_responses_api(model: str, custom_llm_provider: str, deployment_model_info: object) -> bool:
from litellm.responses.bridge_check import responses_api_bridge_check, str_keyed_mapping
model_info, _ = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
deployment_model_info=str_keyed_mapping(deployment_model_info),
)
return model_info.get("mode") == "responses"
def _responses_mode_is_lost_by_prefix_strip(
requested_model: str, resolved_model: str, custom_llm_provider: str, deployment_model_info: object
) -> bool:
"""Whether a Responses-only deployment stops looking like one once its provider prefix is stripped.
``litellm.completion`` re-derives the Responses bridge from the stripped id alone, so a
deployment id such as ``perplexity/perplexity/sonar`` (mode ``responses``) is shadowed by the
chat entry ``perplexity/sonar`` and would otherwise be sent to chat/completions.
"""
if requested_model == resolved_model:
return False
return _bridges_to_responses_api(
requested_model, custom_llm_provider, deployment_model_info
) and not _bridges_to_responses_api(resolved_model, custom_llm_provider, deployment_model_info)
def _messages_bridge_to_responses(
custom_llm_provider: str | None,
requested_model: str,
resolved_model: str,
deployment_model_info: object,
force_chat_completions: bool,
) -> bool:
if force_chat_completions:
return False
if custom_llm_provider in _MESSAGES_TO_RESPONSES_PROVIDERS:
return True
if custom_llm_provider is None:
return False
return _responses_mode_is_lost_by_prefix_strip(
requested_model, resolved_model, custom_llm_provider, deployment_model_info
)
def plan_messages_route(
*,
requested_model: str,
resolved_model: str,
custom_llm_provider: str | None,
model_info: object,
api_base: str | None,
compaction_requested: bool,
force_chat_completions: bool,
) -> RoutePlan:
"""``force_chat_completions`` is ``litellm.use_chat_completions_url_for_anthropic_messages``."""
config: Final = native_messages_config(resolved_model, custom_llm_provider, model_info, api_base)
if config is not None:
return NativeMessages(config)
if not compaction_requested and _messages_bridge_to_responses(
custom_llm_provider, requested_model, resolved_model, model_info, force_chat_completions
):
return NativeResponses()
return NativeChat()
def plan_responses_route(
responses_api_provider_config: BaseResponsesAPIConfig | None, use_chat_completions_api: bool
) -> NativeResponses | NativeChat:
if responses_api_provider_config is None or use_chat_completions_api:
return NativeChat()
return NativeResponses()
def plan_chat_route(responses_mode_model: bool, skip_responses_api_bridge: bool) -> NativeChat | NativeResponses:
"""``responses_mode_model`` is whether ``responses_api_bridge_check`` resolved the deployment to mode ``responses``."""
if responses_mode_model and not skip_responses_api_bridge:
return NativeResponses()
return NativeChat()

View file

@ -40,6 +40,7 @@ from litellm.litellm_core_utils.provider_affinity import add_provider_affinity_h
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.llms.openai_like.responses.transformation import OpenAILikeResponsesConfig
from litellm.native.route_plan import NativeChat, plan_responses_route
from litellm.responses.litellm_completion_transformation.handler import (
LiteLLMCompletionTransformationHandler,
)
@ -532,13 +533,6 @@ async def aresponses_api_with_mcp(
return response
def _bridges_to_chat_completions(
responses_api_provider_config: BaseResponsesAPIConfig | None, use_chat_completions_api: bool
) -> bool:
"""Whether the request reaches its provider as a chat completion, not a Responses call."""
return responses_api_provider_config is None or use_chat_completions_api is True
_RESPONSES_ONLY_REQUEST_FIELDS_NEVER_BRIDGED: Final = frozenset({"client_metadata"})
@ -583,7 +577,7 @@ def _encrypted_task_support_failure(
) -> _ResponsesCompatibilityFailure | None:
if (
responses_api_provider_config is None
or _bridges_to_chat_completions(responses_api_provider_config, use_chat_completions_api)
or isinstance(plan_responses_route(responses_api_provider_config, use_chat_completions_api), NativeChat)
or not responses_api_provider_config.supports_encrypted_agent_messages()
):
return "encrypted_task_unsupported"
@ -657,7 +651,7 @@ def _will_bridge_to_chat_completions(
model_info: object,
api_base: str | None,
) -> bool:
"""``_bridges_to_chat_completions`` for callers running before the provider config is resolved.
"""``plan_responses_route`` for callers running before the provider config is resolved.
Resolving the config is a pure lookup, so this asks the same question the dispatch
asks rather than restating its condition, with the model name the dispatch hands the
@ -670,14 +664,17 @@ def _will_bridge_to_chat_completions(
normalized_model: Final = _normalize_openai_chat_completions_responses_model(model)
if custom_llm_provider is None:
return True
return _bridges_to_chat_completions(
_resolve_responses_api_provider_config(
_dispatched_model_name(normalized_model[0], custom_llm_provider, api_base),
custom_llm_provider,
model_info,
api_base,
return isinstance(
plan_responses_route(
_resolve_responses_api_provider_config(
_dispatched_model_name(normalized_model[0], custom_llm_provider, api_base),
custom_llm_provider,
model_info,
api_base,
),
use_chat_completions_api or normalized_model[1],
),
use_chat_completions_api or normalized_model[1],
NativeChat,
)
@ -1604,7 +1601,7 @@ def responses(
if _file_search_dispatch is not None:
return _file_search_dispatch
if _bridges_to_chat_completions(responses_api_provider_config, use_chat_completions_api):
if isinstance(plan_responses_route(responses_api_provider_config, use_chat_completions_api), NativeChat):
bridge_kwargs: Final = _bridge_kwargs(kwargs, responses_api_provider_config, allowed_openai_params)
return litellm_completion_transformation_handler.response_api_handler(
_previous_response_client_ids(previous_response_id, kwargs),

View file

@ -32,13 +32,7 @@ def _call_handler_and_capture_optional_params(thinking=None, **extra_kwargs):
with patch(
"litellm.llms.anthropic.pass_through.messages.handler."
"base_llm_http_handler"
) as mock_handler, patch(
"litellm.llms.anthropic.pass_through.messages.handler."
"ProviderConfigManager"
) as mock_pcm:
# Make get_provider_anthropic_messages_config return a non-None config
# so the handler takes the native Anthropic path
mock_pcm.get_provider_anthropic_messages_config.return_value = MagicMock()
) as mock_handler:
mock_handler.anthropic_messages_handler.return_value = MagicMock()
kwargs = dict(extra_kwargs)

View file

View file

@ -0,0 +1,98 @@
import pytest
from litellm.llms.anthropic.pass_through.messages.transformation import AnthropicMessagesConfig
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.llms.openai_like.messages.transformation import OpenAILikeAnthropicMessagesConfig
from litellm.native.route_plan import (
NativeChat,
NativeMessages,
NativeResponses,
RoutePlan,
plan_chat_route,
plan_messages_route,
plan_responses_route,
)
MESSAGES_PASSTHROUGH: dict[str, object] = {"supported_endpoints": ["/v1/messages"]}
def _plan(
model: str,
provider: str,
*,
model_info: object = None,
compaction_requested: bool = False,
force_chat_completions: bool = False,
) -> RoutePlan:
return plan_messages_route(
requested_model=f"{provider}/{model}",
resolved_model=model,
custom_llm_provider=provider,
model_info=model_info,
api_base=None,
compaction_requested=compaction_requested,
force_chat_completions=force_chat_completions,
)
def test_a_provider_that_speaks_messages_gets_its_own_messages_config():
plan = _plan("claude-sonnet-4-5", "anthropic")
assert isinstance(plan, NativeMessages)
assert isinstance(plan.config, AnthropicMessagesConfig)
@pytest.mark.parametrize(
("model", "provider", "kwargs", "expected"),
[
("gpt-5.5", "openai", {}, NativeResponses()),
("gpt-5.5", "openai", {"force_chat_completions": True}, NativeChat()),
("gpt-5.5", "openai", {"compaction_requested": True}, NativeChat()),
("my-model", "hosted_vllm", {}, NativeChat()),
],
)
def test_messages_without_a_native_config_bridge_to_responses_only_for_openai(
model: str, provider: str, kwargs: dict[str, bool], expected: RoutePlan
):
assert _plan(model, provider, **kwargs) == expected
@pytest.mark.parametrize("cache_control_ttl", [True, False])
def test_a_deployment_that_opts_into_messages_passthrough_skips_translation(cache_control_ttl: bool):
plan = _plan("my-model", "hosted_vllm", model_info={**MESSAGES_PASSTHROUGH, "cache_control_ttl": cache_control_ttl})
assert isinstance(plan, NativeMessages)
assert isinstance(plan.config, OpenAILikeAnthropicMessagesConfig)
assert plan.config.supports_cache_control_ttl() is cache_control_ttl
@pytest.mark.parametrize(
("has_config", "use_chat_completions_api", "expected"),
[
(True, False, NativeResponses()),
(True, True, NativeChat()),
(False, False, NativeChat()),
(False, True, NativeChat()),
],
)
def test_responses_reach_chat_without_a_provider_config_or_when_asked(
has_config: bool, use_chat_completions_api: bool, expected: RoutePlan
):
config = OpenAIResponsesAPIConfig() if has_config else None
assert plan_responses_route(config, use_chat_completions_api) == expected
@pytest.mark.parametrize(
("responses_mode_model", "skip_responses_api_bridge", "expected"),
[
(True, False, NativeResponses()),
(True, True, NativeChat()),
(False, False, NativeChat()),
(False, True, NativeChat()),
],
)
def test_chat_bridges_to_responses_only_for_responses_mode_models(
responses_mode_model: bool, skip_responses_api_bridge: bool, expected: RoutePlan
):
assert plan_chat_route(responses_mode_model, skip_responses_api_bridge) == expected