fix(openai): handle SDK 3 error shapes, audio deltas, Azure deployment URLs, and httpx2 token exchange errors

OpenAI SDK 3 sends HTTP through httpx2, rejects an empty api_key, and ships new
Responses error codes and types. This maps the new error codes and the SDK 3
missing-credentials message, keeps the raw upstream error code, copies audio
deltas without unset fields, strips operation paths from full Azure deployment
URLs, closes the websocket passthrough cleanly on httpx2 token exchange errors,
and replaces the recursive cause walk with a bounded loop. Tests route the SDK 3
workload identity token exchange through respx and build router clients SDK 3
accepts. The lazy OpenAPI snapshot and dashboard API types are regenerated
This commit is contained in:
mateo-berri 2026-10-21 00:07:57 -07:00
parent 2d2c044346
commit 6517ef7d64
20 changed files with 2062 additions and 157 deletions

View file

@ -271,6 +271,10 @@ class _ProviderHTTPException(Protocol):
_ERROR_BODY_ADAPTER: Final = TypeAdapter(dict[str, object])
_OPENAI_CLIENT_MISSING_CREDENTIALS_MESSAGES: Final = (
"The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable",
"Missing credentials. Please pass an `api_key`",
)
def _openai_cyber_policy_body(original_exception: _ProviderHTTPException) -> Mapping[str, object] | None:
@ -439,10 +443,7 @@ def _map_openai_exception(
litellm_debug_info=extra_information,
body=getattr(original_exception, "body", None),
)
elif (
"The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable"
in error_str
):
elif any(marker in error_str for marker in _OPENAI_CLIENT_MISSING_CREDENTIALS_MESSAGES):
raise AuthenticationError(
message=f"AuthenticationError: {exception_provider} - {message}",
llm_provider=custom_llm_provider,

View file

@ -1056,7 +1056,12 @@ class CustomStreamWrapper:
"""
Copy a specific attribute from source delta to target delta.
"""
setattr(target_delta, attribute_name, getattr(source_delta, attribute_name))
value: Final = stored_attribute(source_delta, attribute_name)
setattr(
target_delta,
attribute_name,
value.model_dump(exclude_unset=True) if isinstance(value, BaseModel) else value,
)
def _has_any_special_delta_attributes(self, delta: object) -> bool:
"""

View file

@ -8,6 +8,7 @@ from contextlib import closing
from functools import lru_cache
from types import MappingProxyType
from typing import Final, Literal, NamedTuple, cast
from urllib.parse import urlsplit, urlunsplit
import httpx
from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI
@ -326,13 +327,26 @@ def get_azure_ad_token_from_oidc(
return azure_ad_token_access_token
_DEPLOYMENT_OPERATION_PATH: Final = re.compile(
r"(?P<deployment>.*/openai/deployments/[^/]+)"
r"/(?:chat/completions|completions|embeddings|audio/(?:transcriptions|translations|speech)|images/(?:generations|edits))/?"
)
def _azure_deployment_base_url(deployment_url: str) -> str:
parts: Final = urlsplit(deployment_url)
operation_path: Final = _DEPLOYMENT_OPERATION_PATH.fullmatch(parts.path)
deployment_path: Final = parts.path if operation_path is None else operation_path["deployment"]
return urlunsplit(parts._replace(path=deployment_path, query="", fragment=""))
def select_azure_base_url_or_endpoint(azure_client_params: dict):
azure_endpoint: Final = azure_client_params.get("azure_endpoint", None)
if azure_endpoint is not None:
if isinstance(azure_endpoint, str):
# see : https://github.com/openai/openai-python/blob/3d61ed42aba652b547029095a7eb269ad4e1e957/src/openai/lib/azure.py#L192
if "/openai/deployments" in azure_endpoint:
# this is base_url, not an azure_endpoint
azure_client_params["base_url"] = azure_endpoint
azure_client_params["base_url"] = _azure_deployment_base_url(azure_endpoint)
azure_client_params.pop("azure_endpoint")
return azure_client_params

View file

@ -1,6 +1,9 @@
import sys
from collections.abc import Iterator
from enum import Enum
from functools import partial
from itertools import accumulate, takewhile
from operator import is_not
from typing import Final, TypeAlias
import httpx
@ -42,13 +45,15 @@ def transport_error_classes(kind: TransportErrorKind) -> tuple[type[BaseExceptio
return tuple(error_class for error_class, error_kind in _transport_errors() if error_kind is kind)
def _explicit_causes(error: BaseException | None, depth: int) -> Iterator[BaseException]:
if error is None or depth == 0:
return
yield error
yield from _explicit_causes(error.__cause__, depth - 1)
def _next_cause(error: BaseException | None, _depth: int) -> BaseException | None:
return None if error is None else error.__cause__
def _explicit_causes(error: BaseException) -> Iterator[BaseException | None]:
causes: Final = accumulate(range(1, _MAX_CAUSE_DEPTH), _next_cause, initial=error)
return takewhile(partial(is_not, None), causes)
def chained_transport_error_kind(error: BaseException) -> TransportErrorKind | None:
kinds: Final = (transport_error_kind(cause) for cause in _explicit_causes(error, _MAX_CAUSE_DEPTH))
kinds: Final = (transport_error_kind(cause) for cause in _explicit_causes(error))
return next((kind for kind in kinds if kind is not None), None)

File diff suppressed because it is too large Load diff

View file

@ -72,7 +72,7 @@ def _failure_details(original: Exception) -> _FailureDetails:
upstream: Final = _FailureDetails.model_validate(body)
return _FailureDetails(
message=upstream.message or mapped.message,
code=upstream.code if upstream.code is not None else mapped.code,
code=upstream.code if "code" in body and body["code"] is not None else mapped.code,
type=upstream.type or mapped.type,
status_code=mapped.status_code,
)

View file

@ -22,6 +22,7 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol, cast
import httpx
import httpx2
import openai
from fastapi import APIRouter, Depends, HTTPException, Request, Response, WebSocket
from fastapi.responses import StreamingResponse
@ -3398,7 +3399,7 @@ async def openai_websocket_proxy_route(
base_target_url: Final = os.getenv("OPENAI_API_BASE") or "https://api.openai.com/"
try:
openai_api_key: Final = await _openai_passthrough_credential(base_target_url)
except (openai.OpenAIError, httpx.HTTPError, LiteLLMOpenAIError):
except (openai.OpenAIError, httpx.HTTPError, httpx2.HTTPError, LiteLLMOpenAIError):
verbose_proxy_logger.exception("OpenAI workload identity token exchange failed for websocket passthrough")
await websocket.close(code=1011, reason=_OPENAI_WS_TOKEN_EXCHANGE_FAILED_REASON)
return

View file

@ -226,6 +226,9 @@ _ERROR_CODE_HTTP_STATUS: Final[Mapping[str, int]] = MappingProxyType(
"insufficient_quota": 429,
"vector_store_timeout": 504,
"invalid_prompt": 400,
"data_residency_mismatch": 400,
"bio_policy": 400,
"misalignment_policy_violation": 403,
"invalid_image": 400,
"invalid_image_format": 400,
"invalid_base64_image": 400,

View file

@ -2,8 +2,10 @@ import asyncio
import base64
import importlib
import os
import sys
from collections.abc import Callable, Coroutine, Iterator
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
@ -427,6 +429,32 @@ def fake_provider_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(name, value)
@pytest.fixture
def openai_token_exchange_via_httpx(monkeypatch: pytest.MonkeyPatch) -> None:
workload_module: Final = sys.modules.get("openai.auth._workload")
native_client_factory: Final = getattr(workload_module, "DefaultHttpx2Client", None)
if native_client_factory is None:
return
import httpx2
def send_through_httpx(request: httpx2.Request) -> httpx2.Response:
with httpx.Client(trust_env=False) as client:
try:
response: Final = client.send(
httpx.Request(request.method, str(request.url), headers=dict(request.headers), content=request.content)
)
except httpx.TransportError as error:
native_error: Final = getattr(httpx2, type(error).__name__, httpx2.TransportError)
raise native_error(str(error), request=request) from error
return httpx2.Response(response.status_code, headers=dict(response.headers), content=response.content)
monkeypatch.setattr(
workload_module,
"DefaultHttpx2Client",
partial(native_client_factory, transport=httpx2.MockTransport(send_through_httpx), trust_env=False),
)
@pytest.fixture
async def drained_logging_worker() -> None:
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.clear_queue(), timeout=10)

View file

@ -2235,10 +2235,8 @@ def test_completion_perplexity_exception_on_openai_client(monkeypatch):
model="perplexity/mistral-7b-instruct",
messages=[{"role": "user", "content": "hello"}],
)
assert (
"The api_key client option must be set either by passing api_key to the client or by setting the PERPLEXITY_API_KEY environment variable"
in str(exc_info.value)
)
assert "PERPLEXITY_API_KEY" in str(exc_info.value)
assert "OPENAI_API_KEY" not in str(exc_info.value)
@pytest.mark.asyncio

View file

@ -160,6 +160,11 @@ async def test_azure_ai_acompletion_posts_the_conversation_to_the_models_endpoin
"/openai/deployments/gpt-4.1-mini/chat/completions",
id="full-deployment-url",
),
pytest.param(
"https://example-resource.services.ai.azure.com/openai/deployments/gpt-4.1-mini/chat/completions",
"/openai/deployments/gpt-4.1-mini/chat/completions",
id="full-deployment-url-without-query",
),
],
)
def test_azure_ai_completion_routes_flexible_api_base(

View file

@ -56,22 +56,7 @@ CHAT_COMPLETION_BODY: Final = {
}
@pytest.fixture(autouse=True)
def mock_sdk_token_exchange_transport(monkeypatch: pytest.MonkeyPatch) -> None:
native_client_factory: Final = getattr(sys.modules.get("openai.auth._workload"), "DefaultHttpx2Client", None)
if native_client_factory is not None:
import httpx2
def handle_exchange(request: httpx2.Request) -> httpx2.Response:
response: Final = respx.mock.handler(
httpx.Request(request.method, str(request.url), headers=dict(request.headers), content=request.content)
)
return httpx2.Response(response.status_code, headers=dict(response.headers), content=response.content)
monkeypatch.setattr(
"openai.auth._workload.DefaultHttpx2Client",
partial(native_client_factory, transport=httpx2.MockTransport(handle_exchange), trust_env=False),
)
pytestmark = pytest.mark.usefixtures("openai_token_exchange_via_httpx")
@pytest.fixture

View file

@ -1119,6 +1119,7 @@ class _ModerationsEndpointThatRecordsTheAuthorization:
@pytest.mark.asyncio
@pytest.mark.usefixtures("openai_token_exchange_via_httpx")
@respx.mock
async def test_openai_moderation_sends_the_federated_bearer_when_only_workload_identity_is_set(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
@ -1149,6 +1150,7 @@ async def test_openai_moderation_sends_the_federated_bearer_when_only_workload_i
@pytest.mark.asyncio
@pytest.mark.usefixtures("openai_token_exchange_via_httpx")
@respx.mock
async def test_openai_moderation_federates_with_the_identity_in_its_own_guardrail_config(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path

View file

@ -4058,7 +4058,7 @@ def test_openai_passthrough_forwards_verbatim_to_openai(
@pytest.fixture
def openai_wif_env(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
def openai_wif_env(monkeypatch: pytest.MonkeyPatch, tmp_path, openai_token_exchange_via_httpx: None) -> None:
from litellm.llms.openai.workload_identity import _workload_identity_auth
token_file: Final = tmp_path / "subject_token.jwt"

View file

@ -182,7 +182,7 @@ TOKEN_EXCHANGE_URL: Final = "https://auth.openai.com/oauth/token"
@pytest.fixture
def openai_wif_token_file(monkeypatch, tmp_path):
def openai_wif_token_file(monkeypatch, tmp_path, openai_token_exchange_via_httpx: None):
token_file = tmp_path / "subject_token.jwt"
token_file.write_text("subject-token-from-file")
monkeypatch.delenv("OPENAI_API_BASE", raising=False)

View file

@ -1081,7 +1081,9 @@ async def test_voice_transcript_blocked_by_guardrail():
@pytest.fixture
def federated_deployment(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> dict[str, str]:
def federated_deployment(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, openai_token_exchange_via_httpx: None
) -> dict[str, str]:
token_file: Final = tmp_path / "subject_token.jwt"
token_file.write_text("subject-token")
for name in (

View file

@ -608,6 +608,9 @@ def test_every_openai_sdk_response_error_code_has_explicit_status_mapping():
("empty_image_file", 400),
("failed_to_download_image", 400),
("image_file_not_found", 400),
("data_residency_mismatch", 400),
("bio_policy", 400),
("misalignment_policy_violation", 403),
("totally_unknown_future_code", 500),
("429", 429),
("503", 503),

View file

@ -26971,7 +26971,7 @@ def test_router_dynamic_cooldown_correct_retry_after_time():
]
)
openai_client = openai.OpenAI(api_key="")
openai_client = openai.OpenAI(api_key="sk-fake")
cooldown_time = 30

View file

@ -95,11 +95,16 @@ def _ratelimit_headers(response: ModelResponse | CustomStreamWrapper) -> dict[st
def _openai_router_client() -> AsyncOpenAI:
return AsyncOpenAI(api_key="sk-fake")
return AsyncOpenAI(api_key="sk-fake", http_client=httpx.AsyncClient())
def _azure_router_client() -> AsyncAzureOpenAI:
return AsyncAzureOpenAI(api_key="sk-fake", azure_endpoint="https://azure.test", api_version="2025-02-01-preview")
return AsyncAzureOpenAI(
api_key="sk-fake",
azure_endpoint="https://azure.test",
api_version="2025-02-01-preview",
http_client=httpx.AsyncClient(),
)
@pytest.mark.asyncio

File diff suppressed because it is too large Load diff