From 104838d59986f0d4c4535a8a48e66d232da1c240 Mon Sep 17 00:00:00 2001 From: narmstrong-berri Date: Thu, 15 Oct 2026 21:57:03 +0000 Subject: [PATCH] refactor: decide the native API format once with plan_route Messages, chat and Responses each decided inline whether to bridge to another API format. The decisions now live in litellm/native/route_plan.py as a tagged union so the upcoming executor split has one place to read --- .github/workflows/test-unit.yml | 1 + .../pass_through/messages/handler.py | 167 +++++------------- litellm/main.py | 6 +- litellm/native/route_plan.py | 162 +++++++++++++++++ litellm/responses/main.py | 31 ++-- .../test_reasoning_auto_summary_messages.py | 8 +- tests/unit/native/__init__.py | 0 tests/unit/native/test_route_plan.py | 98 ++++++++++ 8 files changed, 321 insertions(+), 152 deletions(-) create mode 100644 litellm/native/route_plan.py create mode 100644 tests/unit/native/__init__.py create mode 100644 tests/unit/native/test_route_plan.py diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 5e42f182345..c57ee91acfa 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -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 diff --git a/litellm/llms/anthropic/pass_through/messages/handler.py b/litellm/llms/anthropic/pass_through/messages/handler.py index d0f0fa4d901..73030aae623 100644 --- a/litellm/llms/anthropic/pass_through/messages/handler.py +++ b/litellm/llms/anthropic/pass_through/messages/handler.py @@ -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}" diff --git a/litellm/main.py b/litellm/main.py index 9430af34401..57a6e28cd7a 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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) diff --git a/litellm/native/route_plan.py b/litellm/native/route_plan.py new file mode 100644 index 00000000000..00690543db2 --- /dev/null +++ b/litellm/native/route_plan.py @@ -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() diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 727a5ccfedf..26e9fb5e39f 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -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), diff --git a/tests/unit/llms/anthropic/pass_through/messages/test_reasoning_auto_summary_messages.py b/tests/unit/llms/anthropic/pass_through/messages/test_reasoning_auto_summary_messages.py index cc951421b10..4ae91326c52 100644 --- a/tests/unit/llms/anthropic/pass_through/messages/test_reasoning_auto_summary_messages.py +++ b/tests/unit/llms/anthropic/pass_through/messages/test_reasoning_auto_summary_messages.py @@ -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) diff --git a/tests/unit/native/__init__.py b/tests/unit/native/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/native/test_route_plan.py b/tests/unit/native/test_route_plan.py new file mode 100644 index 00000000000..4a4e6fabe0f --- /dev/null +++ b/tests/unit/native/test_route_plan.py @@ -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