Merge pull request #39587 from BerriAI/litellm_fix_responses_bridge_privatelink_api_base

fix(openai): bridge gpt-5.4+ tool calls to /v1/responses on every api.openai.com host
This commit is contained in:
Mateo Wang 2026-09-03 13:13:27 -07:00 committed by GitHub
commit 0eed564ee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 93 additions and 9 deletions

View file

@ -26,6 +26,7 @@ from copy import deepcopy
from functools import partial
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, Union, cast, get_args
from urllib.parse import urlsplit
from litellm._logging import _redact_string
from litellm._uuid import uuid
@ -984,6 +985,12 @@ def mock_completion(
_OPENAI_DEFAULT_API_BASE: Final = "https://api.openai.com/v1"
_OPENAI_API_HOST: Final = "api.openai.com"
def _is_openai_backed_api_base(api_base: str) -> bool:
hostname: Final = urlsplit(api_base).hostname
return hostname is not None and (hostname == _OPENAI_API_HOST or hostname.endswith(f".{_OPENAI_API_HOST}"))
def _resolve_openai_api_base(api_base: str | None) -> str:
@ -1053,7 +1060,7 @@ def responses_api_bridge_check(
# natively by Chat Completions with reasoning on, so custom-only requests stay on
# chat and keep their native custom tool_call response shape.
# - The UNSET-effort arm only fires against endpoints known to enforce that
# constraint (the default OpenAI endpoint, or Azure OpenAI where api_base is
# constraint (any api.openai.com host, or Azure OpenAI where api_base is
# always set): chat-only OpenAI-compatible backends registered under the openai
# provider with a custom api_base and gpt-5.4+ model names serve tools without
# reasoning fine and have no /responses route, so they keep pre-existing
@ -1068,14 +1075,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"
# 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: Final = _resolve_openai_api_base(api_base)
on_constraint_enforcing_endpoint: Final = custom_llm_provider == "azure" or resolved_api_base.strip() in (
"",
_OPENAI_DEFAULT_API_BASE,
# The reasoning+tools constraint is enforced by the real OpenAI backend behind any api.openai.com
# host (the default URL or a PrivateLink hostname such as <region>.privatelink.api.openai.com) and
# by 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: Final = _resolve_openai_api_base(api_base).strip()
on_constraint_enforcing_endpoint: Final = (
custom_llm_provider == "azure" or resolved_api_base == "" or _is_openai_backed_api_base(resolved_api_base)
)
if (
custom_llm_provider in ("openai", "azure")

View file

@ -1133,6 +1133,82 @@ def test_responses_api_bridge_check_custom_api_base_via_env_with_unset_effort_st
assert model_info.get("mode") != "responses"
@pytest.mark.parametrize(
"api_base",
[
"https://southcentralus.privatelink.api.openai.com/v1",
"https://privatelink.corp.api.openai.com/v1",
"https://api.openai.com:443/v1",
"https://api.openai.com/v1/",
"HTTPS://API.OPENAI.COM/v1",
],
)
def test_responses_api_bridge_check_openai_backed_custom_api_base_with_unset_effort_routes_to_responses(api_base):
"""
A custom api_base whose host is api.openai.com or a subdomain of it (a PrivateLink hostname, a
port-qualified or trailing-slash default) still reaches the real OpenAI backend, which rejects
function tools with reasoning on Chat Completions, so the unset-effort arm must bridge exactly as
it does for the literal default URL. Regression guard for GH #39353.
"""
from litellm.main import responses_api_bridge_check
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=api_base,
)
assert model == "gpt-5.6"
assert model_info.get("mode") == "responses"
@pytest.mark.parametrize(
"api_base",
[
"https://api.openai.com.evil.example/v1",
"https://notapi.openai.com/v1",
"https://gateway.example/v1?upstream=api.openai.com",
"https://openai.internal.example/api.openai.com/v1",
],
)
def test_responses_api_bridge_check_lookalike_custom_api_base_with_unset_effort_stays_chat(api_base):
"""Only the host decides: api.openai.com appearing elsewhere in the URL is still a foreign backend."""
from litellm.main import responses_api_bridge_check
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=api_base,
)
assert model == "gpt-5.6"
assert model_info.get("mode") != "responses"
def test_responses_api_bridge_check_privatelink_api_base_via_env_with_unset_effort_routes_to_responses(monkeypatch):
"""A PrivateLink base set through OPENAI_BASE_URL resolves the way the chat handler's does and still bridges."""
import litellm
from litellm.main import responses_api_bridge_check
monkeypatch.setattr(litellm, "api_base", None)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
monkeypatch.setenv("OPENAI_BASE_URL", "https://southcentralus.privatelink.api.openai.com/v1")
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