fix(bridge): resolve the effective OpenAI base once, shared by gate and chat handler

The gpt-5.4+ responses-bridge gate classified the endpoint from the call-level
api_base alone, while the OpenAI chat handler resolves arg > global > env >
default. A custom base configured via litellm.api_base or OPENAI_BASE_URL/
OPENAI_API_BASE was therefore invisible to the gate: it read blank as the
default OpenAI endpoint and bridged a request the custom backend has no
/responses route for.

Extract that resolution into one _resolve_openai_api_base() and have both the
gate and _complete_custom_openai() call it, so the gate can never classify an
endpoint the request won't hit. The gate compares the resolved base against the
default (import litellm seeds OPENAI_BASE_URL to the default, so "override is
non-None" is not a safe custom-endpoint signal); whitespace collapses to the
default as before. reasoning_effort="none" remains the escape hatch.
This commit is contained in:
Tin Chi Lo 2026-07-25 10:45:41 -07:00
parent c7c656e8a9
commit 4139f548da
2 changed files with 80 additions and 11 deletions

View file

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

View file

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