mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-06 07:48:06 +00:00
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:
parent
2d2c044346
commit
6517ef7d64
20 changed files with 2062 additions and 157 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
929
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
929
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue