diff --git a/litellm/main.py b/litellm/main.py index 4aa6bf9a19b..fbb43dd41fa 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -979,6 +979,23 @@ def mock_completion( raise Exception("Mock completion response failed - {}".format(e)) +_OPENAI_DEFAULT_API_BASE = "https://api.openai.com/v1" + + +def _resolve_openai_api_base(api_base: str | None) -> str: + """Effective OpenAI base a chat request will hit: arg > global > env > default. The bridge gate + and the ``_complete_custom_openai`` chat handler MUST resolve this identically, or a custom base + set via ``litellm.api_base`` or ``OPENAI_BASE_URL``/``OPENAI_API_BASE`` is invisible to the gate, + which then misreads it as the default OpenAI endpoint and bridges a request the backend can't serve.""" + return ( + api_base + or litellm.api_base + or get_secret_str("OPENAI_BASE_URL") + or get_secret_str("OPENAI_API_BASE") + or _OPENAI_DEFAULT_API_BASE + ) + + def responses_api_bridge_check( model: str, custom_llm_provider: str, @@ -1047,10 +1064,15 @@ def responses_api_bridge_check( reasoning_active = reasoning_effort.get("effort") != "none" or reasoning_effort.get("summary") is not None else: reasoning_active = reasoning_effort != "none" - # A blank api_base (None, "", or whitespace) is not a custom endpoint: it resolves - # to the default OpenAI base downstream, which does enforce the reasoning+tools - # constraint. Azure always targets an OpenAI-constraint endpoint regardless. - on_constraint_enforcing_endpoint = custom_llm_provider == "azure" or not (api_base and api_base.strip()) + # The reasoning+tools constraint is enforced only by the real OpenAI endpoint (and Azure OpenAI). + # Resolve the effective base arg>global>env>default exactly as the chat handler does, so a custom + # base set via litellm.api_base or OPENAI_BASE_URL/OPENAI_API_BASE isn't misread as the default and + # bridged to a /responses route it lacks. A whitespace-only base collapses to the default too. + resolved_api_base = _resolve_openai_api_base(api_base) + on_constraint_enforcing_endpoint = custom_llm_provider == "azure" or resolved_api_base.strip() in ( + "", + _OPENAI_DEFAULT_API_BASE, + ) if ( custom_llm_provider in ("openai", "azure") and model_info.get("mode") != "responses" @@ -2396,13 +2418,8 @@ def _complete_custom_openai( stream = ctx.stream timeout = ctx.timeout - api_base = ( - api_base # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there - or litellm.api_base - or get_secret("OPENAI_BASE_URL") - or get_secret("OPENAI_API_BASE") - or "https://api.openai.com/v1" - ) + # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there + api_base = _resolve_openai_api_base(api_base) organization = ( organization or litellm.organization diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 057d11e1ecd..9e160370048 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -1043,6 +1043,58 @@ def test_responses_api_bridge_check_custom_api_base_with_unset_effort_stays_chat assert model_info.get("mode") != "responses" +def test_responses_api_bridge_check_custom_api_base_via_global_with_unset_effort_stays_chat(monkeypatch): + """ + A custom base set through the litellm.api_base global (not the call arg) is resolved the + same way the chat handler resolves it, so the unset-effort arm must not reroute a chat-only + backend to a /responses route it lacks. Regression guard: the gate previously inspected only + the call-level api_base and bridged these requests. + """ + import litellm + from litellm.main import responses_api_bridge_check + + monkeypatch.setattr(litellm, "api_base", "http://vllm.internal:8000/v1") + with patch("litellm.main._get_model_info_helper") as mock_get_model_info: + mock_get_model_info.return_value = {"max_tokens": 128000} + model_info, model = responses_api_bridge_check( + model="gpt-5.6", + custom_llm_provider="openai", + tools=[{"type": "function", "function": {"name": "get_capital"}}], + reasoning_effort=None, + api_base=None, + ) + + assert model == "gpt-5.6" + assert model_info.get("mode") != "responses" + + +@pytest.mark.parametrize("env_var", ["OPENAI_BASE_URL", "OPENAI_API_BASE"]) +def test_responses_api_bridge_check_custom_api_base_via_env_with_unset_effort_stays_chat(monkeypatch, env_var): + """ + A custom base set via OPENAI_BASE_URL/OPENAI_API_BASE env is resolved identically to the chat + handler, so the unset-effort arm leaves the request on chat instead of bridging it. + """ + import litellm + from litellm.main import responses_api_bridge_check + + monkeypatch.setattr(litellm, "api_base", None) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + monkeypatch.setenv(env_var, "http://vllm.internal:8000/v1") + with patch("litellm.main._get_model_info_helper") as mock_get_model_info: + mock_get_model_info.return_value = {"max_tokens": 128000} + model_info, model = responses_api_bridge_check( + model="gpt-5.6", + custom_llm_provider="openai", + tools=[{"type": "function", "function": {"name": "get_capital"}}], + reasoning_effort=None, + api_base=None, + ) + + assert model == "gpt-5.6" + assert model_info.get("mode") != "responses" + + def test_responses_api_bridge_check_custom_api_base_with_explicit_effort_still_routes(): """Explicit reasoning_effort keeps its pre-existing bridging behavior on any api_base.""" from litellm.main import responses_api_bridge_check