This commit is contained in:
Mateo Wang 2026-11-07 01:53:12 +00:00 • committed by GitHub
commit 95a7bf74ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 4359 additions and 680 deletions

View file

@ -70,6 +70,12 @@ trap 'exit 143' TERM
export PATH="$PWD/.venv/bin:$PATH"
export PYTHONPATH="$PWD:$PWD/tests:$PWD/tests/e2e"
if [ "$suite" = extensions ]; then
retained_openai="$(mktemp -d)/retained-openai"
uv venv --quiet --python 3.12 "$retained_openai"
uv pip install --quiet --python "$retained_openai/bin/python" --exclude-newer 2026-04-29T00:00:00Z openai==2.33.0
export RETAINED_OPENAI_PYTHON="$retained_openai/bin/python"
fi
export DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/circle_test"
export REDIS_HOST=127.0.0.1 REDIS_PORT=6379
export LITELLM_MASTER_KEY="sk-$(openssl rand -hex 16)" LITELLM_SALT_KEY=sk-integration-salt
@ -271,6 +277,7 @@ env -i PATH="$PATH" HOME="$HOME" PYTHONPATH="$PYTHONPATH" \
INTEGRATION_PROXY_READ_REPLICA_URL="$INTEGRATION_PROXY_READ_REPLICA_URL" \
INTEGRATION_ROUTING="$INTEGRATION_ROUTING" \
MCP_TEST_PEER_PYTHON="${MCP_TEST_PEER_PYTHON:-}" \
RETAINED_OPENAI_PYTHON="${RETAINED_OPENAI_PYTHON:-}" \
.venv/bin/python tests/integration/run.py "$suite" --results "$results" \
"${shard_arguments[@]}"

View file

@ -1406,7 +1406,7 @@ jobs:
run: |
.github/scripts/uv_sync_with_retries.sh --frozen --group dev --group proxy-dev --extra proxy --no-install-workspace
cp rust-bridge-dist/litellm/rust_bridge/_native.* litellm/rust_bridge/
uv pip install "openai==${OPENAI_VERSION:?}" --exclude-newer-package openai=2026-10-07T00:00:00Z
uv pip install "openai==${OPENAI_VERSION:?}" --override <(echo "openai==${OPENAI_VERSION:?}") --exclude-newer-package openai=2026-10-07T00:00:00Z
uv run --no-sync python -c 'import os, openai; assert openai.__version__ == os.environ["OPENAI_VERSION"], openai.__version__'
- name: Run OpenAI SDK compatibility tests

View file

@ -357,7 +357,7 @@ def _json_field(value: object, key: str) -> object:
def _tool_search_replay_item(item: object) -> Mapping[str, object] | None:
raw: Final[object] = item.model_dump(exclude_unset=True) if isinstance(item, BaseModel) else item
raw: Final[object] = item.model_dump(exclude_unset=True, by_alias=True) if isinstance(item, BaseModel) else item
if not isinstance(raw, dict):
return None
fields: Final = cast(dict[str, object], raw) # cast-ok: provider JSON item, keys forwarded verbatim
@ -2053,7 +2053,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
from pydantic import BaseModel
if isinstance(str_line, BaseModel):
return self.chunk_parser(str_line.model_dump())
return self.chunk_parser(str_line.model_dump(exclude_unset=True))
if not str_line or str_line.startswith("event:"):
# ignore.

View file

@ -11,8 +11,9 @@
import datetime
import enum
import sys
from collections.abc import Mapping, Sequence
from typing import Any, Final, Protocol, SupportsIndex, SupportsInt, runtime_checkable
from typing import TYPE_CHECKING, Any, Final, Protocol, SupportsIndex, SupportsInt, runtime_checkable
import httpx
import openai
@ -22,6 +23,9 @@ import litellm
from litellm.types.utils import LiteLLMCommonStrings
from litellm.types.vector_stores import VectorStoreSearchFailure
if TYPE_CHECKING:
import httpx2
class RateLimitErrorCategory(str, enum.Enum):
"""
@ -117,17 +121,29 @@ def validate_rate_limit_type(value: object) -> str | None:
return None
def _upstream_body(response: object) -> bytes:
if not isinstance(response, httpx.Response):
def upstream_http_response(upstream: object) -> "httpx.Response | httpx2.Response | None":
if isinstance(upstream, httpx.Response):
return upstream
if "httpx2" not in sys.modules:
return None
import httpx2
return upstream if isinstance(upstream, httpx2.Response) else None
def _upstream_body(response: "httpx.Response | httpx2.Response | None") -> bytes:
if response is None:
return b""
try:
return response.content
except httpx.ResponseNotRead:
except RuntimeError:
return b""
def _request_without_credentials(upstream: object, fallback_url: str) -> httpx.Request:
if not isinstance(upstream, httpx.Response):
def _request_without_credentials(
upstream: "httpx.Response | httpx2.Response | None", fallback_url: str
) -> httpx.Request:
if upstream is None:
return httpx.Request(method="POST", url=fallback_url)
try:
upstream_request: Final = upstream.request
@ -135,12 +151,12 @@ def _request_without_credentials(upstream: object, fallback_url: str) -> httpx.R
return httpx.Request(method="POST", url=fallback_url)
return httpx.Request(
method=upstream_request.method,
url=upstream_request.url.copy_with(userinfo=b"", query=None, fragment=None),
url=httpx.URL(str(upstream_request.url)).copy_with(userinfo=b"", query=None, fragment=None),
)
def _upstream_elapsed(upstream: object) -> datetime.timedelta | None:
if not isinstance(upstream, httpx.Response):
def _upstream_elapsed(upstream: "httpx.Response | httpx2.Response | None") -> datetime.timedelta | None:
if upstream is None:
return None
try:
return upstream.elapsed
@ -148,15 +164,26 @@ def _upstream_elapsed(upstream: object) -> datetime.timedelta | None:
return None
def _upstream_headers(upstream: "httpx.Response | httpx2.Response | None") -> httpx.Headers:
match upstream:
case None:
return httpx.Headers()
case httpx.Response(headers=headers):
return httpx.Headers(headers)
case _:
return httpx.Headers(upstream.headers.multi_items())
def error_response_without_credentials(status_code: int, upstream: object, fallback_url: str) -> httpx.Response:
upstream_response: Final = upstream_http_response(upstream)
response: Final = httpx.Response(
status_code=status_code,
stream=httpx.ByteStream(_upstream_body(upstream)),
request=_request_without_credentials(upstream, fallback_url),
stream=httpx.ByteStream(_upstream_body(upstream_response)),
request=_request_without_credentials(upstream_response, fallback_url),
)
_ = response.read()
response.headers = httpx.Headers(upstream.headers if isinstance(upstream, httpx.Response) else None)
elapsed: Final = _upstream_elapsed(upstream)
response.headers = _upstream_headers(upstream_response)
elapsed: Final = _upstream_elapsed(upstream_response)
if elapsed is not None:
response.elapsed = elapsed
return response

View file

@ -21,6 +21,7 @@ from litellm.litellm_core_utils.core_helpers import as_str_mapping, attribute_or
from litellm.litellm_core_utils.secret_redaction import redact_string as redact_secret_string
from litellm.llms.bedrock.common_utils import bedrock_model_reaches_converse_by_default
from litellm.llms.bigmodel.common_utils import raise_bigmodel_business_error
from litellm.llms.custom_httpx.transport_errors import TransportErrorKind, transport_error_classes
from litellm.types.utils import LlmProviders
from ..exceptions import (
@ -40,6 +41,7 @@ from ..exceptions import (
Timeout,
UnprocessableEntityError,
error_response_without_credentials,
upstream_http_response,
)
@ -277,6 +279,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:
@ -311,9 +317,10 @@ def _litellm_proxy_response(
def _without_credentials(response: httpx.Response | None) -> httpx.Response | None:
if not isinstance(response, httpx.Response):
upstream: Final = upstream_http_response(response)
if upstream is None:
return response
return error_response_without_credentials(response.status_code, response, "https://docs.litellm.ai/docs")
return error_response_without_credentials(upstream.status_code, upstream, "https://docs.litellm.ai/docs")
def _upstream_reply(response: httpx.Response | None) -> httpx.Response | None:
@ -476,10 +483,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,
@ -2169,7 +2173,7 @@ def _map_azure_exception(
response=response,
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" in error_str:
elif any(marker in error_str for marker in _OPENAI_CLIENT_MISSING_CREDENTIALS_MESSAGES):
raise AuthenticationError(
message=f"{exception_provider} AuthenticationError - {message}",
llm_provider=custom_llm_provider,
@ -2630,7 +2634,7 @@ def exception_type(
################################################################################
if (
isinstance(original_exception, httpx.TimeoutException)
isinstance(original_exception, transport_error_classes(TransportErrorKind.TIMEOUT))
or "Request Timeout Error" in error_str
or "Request timed out" in error_str
or "Timed out generating response" in error_str

View file

@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol, TypeVar, cast
import anyio
import httpx
from openai.types import Completion
from openai.types.chat import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_chunk import ChoiceDelta
@ -46,6 +47,7 @@ from litellm.litellm_core_utils.model_response_utils import (
)
from litellm.litellm_core_utils.redact_messages import LiteLLMLoggingObject, drop_token_ids
from litellm.litellm_core_utils.thread_pool_executor import executor
from litellm.llms.custom_httpx.transport_errors import TransportErrorKind, chained_transport_error_kind
from litellm.llms.ollama.common_utils import OllamaResponseIdNormalizer
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.router import GenericLiteLLMParams
@ -65,7 +67,7 @@ from litellm.types.utils import (
)
from litellm.types.utils import GenericStreamingChunk as GChunk
from ..exceptions import OpenAIError, Timeout
from ..exceptions import OpenAIError
from .core_helpers import map_finish_reason, process_response_headers
from .exception_mapping_utils import exception_type, with_deployment_api_base
from .llm_response_utils.get_api_base import get_api_base
@ -103,6 +105,28 @@ def _next_sync_or_exhausted(it: Iterator[object]) -> object:
return _SYNC_ITER_EXHAUSTED
def _choice_progress(chunk: object) -> tuple[tuple[int, bool], ...]:
match chunk:
case ChatCompletionChunk(choices=choices):
return tuple((choice.index, bool(choice.finish_reason)) for choice in choices or ())
case ModelResponseStream(choices=choices):
return tuple((choice.index, bool(choice.finish_reason)) for choice in choices or ())
case Completion(choices=choices):
return tuple((choice.index, bool(choice.finish_reason)) for choice in choices or ())
case {"is_finished": bool() as is_finished}:
return ((0, is_finished),)
case _:
return ()
def _ends_every_choice(chunk: object) -> bool:
match chunk:
case {"is_finished": True}:
return True
case _:
return False
def _stamp_usage_cost(
usage_chunk: ModelResponse | TextCompletionResponse | ModelResponseStream,
complete_response: ModelResponse | TextCompletionResponse | ModelResponseStream,
@ -227,12 +251,6 @@ def _delta_from_upstream(upstream_delta: "BaseModel | _UpstreamDeltaLike | None"
return None
def _is_transport_timeout(error: BaseException) -> bool:
return isinstance(error, httpx.TimeoutException) or (
isinstance(error, Timeout) and isinstance(error.__cause__, httpx.TimeoutException)
)
def _skips_stream_fallback(status_code: int | None) -> bool:
return status_code is not None and 400 <= status_code < 500 and status_code != 429
@ -458,6 +476,8 @@ class CustomStreamWrapper:
self.received_finish_choice_fields: Mapping[int, Mapping[str, object]] = _NO_CHOICE_FIELDS_BY_INDEX
self.carried_chunk_choice_fields: Mapping[int, Mapping[str, object]] = _NO_CHOICE_FIELDS_BY_INDEX
self.intermittent_finish_reason: str | None = None # finish reasons that show up mid-stream
self._streamed_choice_indices: frozenset[int] = frozenset()
self._finished_choice_indices: frozenset[int] = frozenset()
self.received_any_finish_reason = False # a later chunk can reset intermittent_finish_reason
self.special_tokens = [
"<|assistant|>",
@ -1230,11 +1250,16 @@ class CustomStreamWrapper:
"""
return delta is not None and stored_attribute(delta, attribute_name) is not None
def _copy_delta_attribute(self, source_delta, target_delta, attribute_name: str) -> None:
def _copy_delta_attribute(self, source_delta: object, target_delta: object, attribute_name: str) -> None:
"""
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:
"""
@ -1492,6 +1517,7 @@ class CustomStreamWrapper:
completion_obj: dict[str, object],
) -> _ProviderChunkResult:
response_obj: dict[str, Any] = {}
self._record_choice_progress(chunk)
if (
isinstance(chunk, ModelResponseStream)
and self.custom_llm_provider is not None
@ -2342,7 +2368,7 @@ class CustomStreamWrapper:
):
chunk = self.completion_stream
else:
chunk = next(self.completion_stream)
chunk = self._next_sync_chunk()
if chunk is not None and chunk != b"":
print_verbose(
f"PROCESSED CHUNK PRE CHUNK CREATOR: {chunk.decode('utf-8', errors='replace') if isinstance(chunk, bytes) else chunk}; custom_llm_provider: {self.custom_llm_provider}"
@ -2513,6 +2539,31 @@ class CustomStreamWrapper:
self.close()
self._handle_stream_fallback_error(e)
def _next_sync_chunk(self) -> object:
try:
return next(self.completion_stream)
except Exception as e:
if (
not self._every_choice_finished()
or chained_transport_error_kind(e) is not TransportErrorKind.DROPPED_CONNECTION
):
raise
raise StopIteration from e
def _record_choice_progress(self, chunk: object) -> None:
progress: Final = _choice_progress(chunk)
self._streamed_choice_indices = self._streamed_choice_indices.union(index for index, _ in progress)
self._finished_choice_indices = (
self._streamed_choice_indices
if _ends_every_choice(chunk)
else self._finished_choice_indices.union(index for index, finished in progress if finished)
)
def _every_choice_finished(self) -> bool:
return (
self.received_finish_reason is not None and self._streamed_choice_indices <= self._finished_choice_indices
)
def fetch_sync_stream(self):
if self.completion_stream is None and self.make_call is not None:
# Call make_call to get the completion stream
@ -2624,21 +2675,20 @@ class CustomStreamWrapper:
await self.aclose()
self._log_stream_failure_and_raise(self._stream_eof_error())
return await self._finalize_completed_stream(cache_hit=cache_hit)
except httpx.TimeoutException as e: # if httpx read timeout error occues
traceback_exception = traceback.format_exc()
## ADD DEBUG INFORMATION - E.G. LITELLM REQUEST TIMEOUT
traceback_exception += f"\nLiteLLM Default Request Timeout - {litellm.request_timeout}"
self._dispatch_stream_failure(e, traceback_exception)
await self.aclose()
self._handle_stream_fallback_error(e)
except (httpx.ReadError, httpx.RemoteProtocolError) as e:
if self.received_finish_reason is None:
await self.aclose()
self._log_stream_failure_and_raise(e)
return await self._finalize_completed_stream(cache_hit=cache_hit)
except Exception as e:
await self.aclose()
self._log_stream_failure_and_raise(e)
match chained_transport_error_kind(e):
case TransportErrorKind.DROPPED_CONNECTION if self._every_choice_finished():
return await self._finalize_completed_stream(cache_hit=cache_hit)
case TransportErrorKind.TIMEOUT:
traceback_exception: Final = (
f"{traceback.format_exc()}\nLiteLLM Default Request Timeout - {litellm.request_timeout}"
)
self._dispatch_stream_failure(e, traceback_exception)
await self.aclose()
self._handle_stream_fallback_error(e)
case _:
await self.aclose()
self._log_stream_failure_and_raise(e)
async def _finalize_completed_stream(self, cache_hit: bool) -> "ModelResponseStream":
if self.sent_last_chunk is True:
@ -2930,7 +2980,7 @@ class CustomStreamWrapper:
# Raise non-retriable client errors directly (skip fallback).
# Exception: 429 (rate-limit) IS retriable/transient — allow it
# through so the Router can switch to a different model group.
if not _is_transport_timeout(e) and (
if chained_transport_error_kind(e) is not TransportErrorKind.TIMEOUT and (
_skips_stream_fallback(mapped_status_code) or _skips_stream_fallback(original_status_code)
):
raise mapped_exception

View file

@ -20,7 +20,7 @@ from ...types.llms.openai import (
SyncCursorPage,
Thread,
)
from .common_utils import BaseAzureLLM
from .common_utils import BaseAzureLLM, with_one_azure_sdk_credential
class _RunThreadStreamData(TypedDict):
@ -57,7 +57,7 @@ class AzureAssistantsAPI(BaseAzureLLM):
api_version=api_version,
is_async=False,
)
azure_openai_client = AzureOpenAI(**azure_client_params)
azure_openai_client = AzureOpenAI(**with_one_azure_sdk_credential(azure_client_params))
else:
azure_openai_client = client
@ -84,7 +84,7 @@ class AzureAssistantsAPI(BaseAzureLLM):
is_async=True,
)
azure_openai_client = AsyncAzureOpenAI(**azure_client_params)
azure_openai_client = AsyncAzureOpenAI(**with_one_azure_sdk_credential(azure_client_params))
# azure_openai_client = AsyncAzureOpenAI(**data)
else:
azure_openai_client = client

View file

@ -1,7 +1,7 @@
import asyncio
import json
import time
from collections.abc import Callable, Coroutine
from collections.abc import Callable, Coroutine, Mapping
from typing import Final, Protocol
import httpx
@ -13,7 +13,7 @@ from openai import (
BadRequestError,
OpenAI,
)
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
import litellm
from litellm.constants import DEFAULT_MAX_RETRIES
@ -53,6 +53,7 @@ from .common_utils import (
process_azure_headers,
redact_azure_auth_headers,
select_azure_base_url_or_endpoint,
with_one_azure_sdk_credential,
)
from .image_generation import (
AzureFoundryMAIImageGenerationConfig,
@ -144,6 +145,16 @@ def check_dynamic_azure_params(
_check_dynamic_azure_params = check_dynamic_azure_params
class _AzureDefaultQuery(BaseModel):
model_config = ConfigDict(frozen=True)
default_query: Mapping[str, str] | None = None
def _with_default_query(url: str, default_query: Mapping[str, str] | None) -> str:
return str(httpx.URL(url).copy_merge_params(default_query)) if default_query else url
class AzureChatCompletion(BaseAzureLLM, BaseLLM):
def __init__(self) -> None:
super().__init__()
@ -952,6 +963,7 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
"/"
)
api_version: Final[str] = azure_client_params.get("api_version", "")
default_query: Final = _AzureDefaultQuery.model_validate(azure_client_params).default_query
if model is None:
model = ""
@ -976,17 +988,12 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
route="/openai/images/generations",
)
if v1_url is not None:
return v1_url
return _with_default_query(v1_url, default_query)
if "/openai/deployments/" in api_base:
base_url_with_deployment = api_base
else:
base_url_with_deployment = api_base + "/openai/deployments/" + model
base_url_with_deployment += "/images/generations"
base_url_with_deployment += "?api-version=" + api_version
return base_url_with_deployment
deployment_url: Final = (
api_base if "/openai/deployments/" in api_base else f"{api_base}/openai/deployments/{model}"
)
return _with_default_query(f"{deployment_url}/images/generations?api-version={api_version}", default_query)
async def aimage_generation(
self,
@ -1338,21 +1345,29 @@ class AzureChatCompletion(BaseAzureLLM, BaseLLM):
api_base += "/"
api_base += f"{model}"
client = AzureOpenAI(
base_url=api_base,
api_version=api_version,
api_key=api_key,
timeout=timeout,
http_client=client_session,
**with_one_azure_sdk_credential(
{
"base_url": api_base,
"api_version": api_version,
"api_key": api_key,
"timeout": timeout,
"http_client": client_session,
}
)
)
model = None
# cloudflare ai gateway, needs model=None
else:
client = AzureOpenAI(
api_version=api_version,
azure_endpoint=api_base,
api_key=api_key,
timeout=timeout,
http_client=client_session,
**with_one_azure_sdk_credential(
{
"api_version": api_version,
"azure_endpoint": api_base,
"api_key": api_key,
"timeout": timeout,
"http_client": client_session,
}
)
)
# only run this check if it's not cloudflare ai gateway

View file

@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable, Iterator, Mapping
from contextlib import closing
from types import MappingProxyType
from typing import Final, Literal, NamedTuple, NoReturn, cast
from urllib.parse import SplitResult, parse_qsl, urlsplit, urlunsplit
import httpx
from openai import APIError, AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI
@ -22,7 +23,12 @@ from litellm.litellm_core_utils.single_flight import SingleFlight, single_flight
from litellm.litellm_core_utils.url_utils import ensure_api_route
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.custom_httpx.http_handler import HTTPHandler, MaskedHTTPStatusError
from litellm.llms.openai.common_utils import BaseOpenAILLM, OpenAIAsyncHTTPClient, OpenAIHTTPClient
from litellm.llms.openai.common_utils import (
BaseOpenAILLM,
OpenAIAsyncHTTPClient,
OpenAIHTTPClient,
openai_sdk_key_params,
)
from litellm.secret_managers.get_azure_ad_token_provider import (
get_azure_ad_token_provider,
single_flight_bearer_token_provider,
@ -62,6 +68,17 @@ def _callable_azure_ad_token_provider(provider: object) -> Callable[[], str | Aw
return _AZURE_AD_TOKEN_PROVIDER_ADAPTER.validate_python(provider) if callable(provider) else None
@with_config(ConfigDict(extra="allow"))
class _AzureSdkClientParams(TypedDict, total=False, extra_items=object):
api_key: ReadOnly[str | None]
azure_ad_token: ReadOnly[str | None]
azure_ad_token_provider: ReadOnly[Callable[[], str] | None]
default_headers: ReadOnly[Mapping[str, str]]
_AZURE_SDK_CLIENT_PARAMS_ADAPTER: Final = TypeAdapter(_AzureSdkClientParams)
class _AzureGatewayClientParams(TypedDict, total=False, extra_items=object):
api_version: ReadOnly[str]
base_url: ReadOnly[str]
@ -535,16 +552,36 @@ def get_azure_ad_token_from_oidc(
)
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:
# 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.pop("azure_endpoint")
_DEPLOYMENT_OPERATION_PATH: Final = re.compile(
r"(?P<deployment>.*/openai/deployments/[^/]+(?:/extensions)?)"
r"/(?:chat/completions|completions|embeddings|audio/(?:transcriptions|translations|speech)|images/(?:generations|edits))/?"
)
return azure_client_params
def _azure_deployment_base_url(deployment_url: SplitResult) -> str:
operation_path: Final = _DEPLOYMENT_OPERATION_PATH.fullmatch(deployment_url.path)
deployment_path: Final = deployment_url.path if operation_path is None else operation_path["deployment"]
return urlunsplit(deployment_url._replace(path=deployment_path, query="", fragment=""))
def _azure_deployment_extra_query(deployment_url: SplitResult) -> Mapping[str, str]:
return {
name: value for name, value in parse_qsl(deployment_url.query, keep_blank_values=True) if name != "api-version"
}
def select_azure_base_url_or_endpoint(azure_client_params: Mapping[str, object]):
azure_endpoint: Final = azure_client_params.get("azure_endpoint", None)
# see : https://github.com/openai/openai-python/blob/3d61ed42aba652b547029095a7eb269ad4e1e957/src/openai/lib/azure.py#L192
if not isinstance(azure_endpoint, str) or "/openai/deployments" not in azure_endpoint:
return dict(azure_client_params)
deployment_url: Final = urlsplit(azure_endpoint)
extra_query: Final = _azure_deployment_extra_query(deployment_url)
return {
**{name: value for name, value in azure_client_params.items() if name != "azure_endpoint"},
"base_url": _azure_deployment_base_url(deployment_url),
**({"default_query": extra_query} if extra_query else {}),
}
def get_azure_ad_token(
@ -669,6 +706,25 @@ _AZURE_AUTH_HEADER_NAMES: Final = frozenset(("api-key", "authorization"))
_REDACTED_AZURE_HEADER_VALUE: Final = "***REDACTED***"
_AZURE_SDK_AUTH_PARAMS: Final = ("azure_ad_token", "azure_ad_token_provider", "api_key")
def with_one_azure_sdk_credential(azure_client_params: Mapping[str, object]) -> _AzureSdkClientParams:
without_auth: Final = {
name: value for name, value in azure_client_params.items() if name not in _AZURE_SDK_AUTH_PARAMS
}
chosen: Final = next(
(name for name in _AZURE_SDK_AUTH_PARAMS[:-1] if azure_client_params.get(name) is not None), "api_key"
)
credential: Final = azure_client_params.get(chosen)
bearer_for_api_key: Final = (
{"default_headers": {"Authorization": f"Bearer {credential}"}}
if chosen == "api_key" and isinstance(credential, str) and credential
else {}
)
return _AZURE_SDK_CLIENT_PARAMS_ADAPTER.validate_python({**without_auth, chosen: credential, **bearer_for_api_key})
def _resolve_azure_ad_token(azure_client_params: Mapping[str, object]) -> str | None:
azure_ad_token: Final = azure_client_params.get("azure_ad_token")
if isinstance(azure_ad_token, str) and azure_ad_token:
@ -819,10 +875,12 @@ class BaseAzureLLM(BaseOpenAILLM):
else v1_token_provider
)
v1_params: Final[_AzureV1ClientParams] = {
"api_key": v1_api_key,
"base_url": f"{ensure_api_route(api_base or '', 'openai/v1')}/",
}
v1_base_url: Final = f"{ensure_api_route(api_base or '', 'openai/v1')}/"
v1_params: Final[_AzureV1ClientParams] = (
{"api_key": v1_api_key, "base_url": v1_base_url}
if callable(v1_api_key)
else {**openai_sdk_key_params(v1_api_key), "base_url": v1_base_url}
)
if "timeout" in azure_client_params:
v1_params["timeout"] = azure_client_params["timeout"]
if "max_retries" in azure_client_params:
@ -830,7 +888,7 @@ class BaseAzureLLM(BaseOpenAILLM):
if "http_client" in azure_client_params:
v1_params["http_client"] = azure_client_params["http_client"]
verbose_logger.debug("Using Azure v1 API with base_url: %s", v1_params["base_url"])
verbose_logger.debug("Using Azure v1 API with base_url: %s", v1_base_url)
if _is_async is True:
openai_client = AsyncOpenAI(**v1_params)
@ -840,7 +898,9 @@ class BaseAzureLLM(BaseOpenAILLM):
# Traditional Azure API uses AzureOpenAI client
if _is_async is True:
async_azure_client_params: Final[_AsyncAzureClientParams] = (
_ASYNC_AZURE_CLIENT_PARAMS_ADAPTER.validate_python(azure_client_params)
_ASYNC_AZURE_CLIENT_PARAMS_ADAPTER.validate_python(
with_one_azure_sdk_credential(azure_client_params)
)
)
async_token_provider_candidate: Final = async_azure_client_params.get("azure_ad_token_provider")
async_azure_client_params_with_threaded_provider: Final[_AsyncAzureClientParams] = (
@ -853,7 +913,7 @@ class BaseAzureLLM(BaseOpenAILLM):
)
openai_client = AsyncAzureOpenAI(**async_azure_client_params_with_threaded_provider)
else:
openai_client = AzureOpenAI(**azure_client_params)
openai_client = AzureOpenAI(**with_one_azure_sdk_credential(azure_client_params))
# save client in-memory cache
self.set_cached_openai_client(
@ -1013,9 +1073,9 @@ class BaseAzureLLM(BaseOpenAILLM):
azure_client_params["azure_ad_token_provider"] = azure_ad_token_provider
if acompletion is True:
client = AsyncAzureOpenAI(**azure_client_params)
client = AsyncAzureOpenAI(**with_one_azure_sdk_credential(azure_client_params))
else:
client = AzureOpenAI(**azure_client_params)
client = AzureOpenAI(**with_one_azure_sdk_credential(azure_client_params))
return client
@staticmethod

View file

@ -110,13 +110,30 @@ def map_aiohttp_exceptions() -> Generator[None, None, None]:
raise mapped_exc(message) from exc
def _unread_payload(content: object) -> bytes:
"""Bytes the upstream sent before the connection dropped that aiohttp buffered but never handed out, since its
reads raise the stored payload error ahead of the buffer."""
if not isinstance(content, aiohttp.StreamReader):
@contextlib.contextmanager
def _map_stream_read_errors() -> Generator[None, None, None]:
try:
yield
except RuntimeError as e:
if "Connection closed" not in str(e):
raise
raise httpx.ReadError(str(e)) from e
except aiohttp.http_exceptions.TransferEncodingError as e:
raise httpx.ReadError(str(e)) from e
except Exception:
with map_aiohttp_exceptions():
raise
def _received_unread_bytes(content: aiohttp.StreamReader) -> bytes:
read_buffer: Final[Callable[[int], object] | None] = getattr(content, "_read_nowait", None)
if read_buffer is None:
return b""
buffered: Final = b"".join(content._buffer) # pyright: ignore[reportPrivateUsage] # no public read drains it
return buffered[content._buffer_offset :] # pyright: ignore[reportPrivateUsage] # see above
try:
unread: Final = read_buffer(-1)
except (AttributeError, TypeError, TimeoutError):
return b""
return unread if isinstance(unread, bytes) else b""
def _release_transport_anchor(_transport: object) -> None:
@ -144,24 +161,15 @@ class AiohttpResponseStream(httpx.AsyncByteStream):
self._aiohttp_response = aiohttp_response
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
content: Final = self._aiohttp_response.content
try:
async for chunk in self._aiohttp_response.content.iter_chunked(self.CHUNK_SIZE):
async for chunk in content.iter_chunked(self.CHUNK_SIZE):
yield chunk
except RuntimeError as e:
if "Connection closed" not in str(e):
raise
raise httpx.ReadError(str(e)) from e
except aiohttp.http_exceptions.TransferEncodingError as e:
raise httpx.ReadError(str(e)) from e
except aiohttp.ClientPayloadError:
unread: Final = _unread_payload(self._aiohttp_response.content)
except Exception:
unread: Final = _received_unread_bytes(content)
if unread:
yield unread
with map_aiohttp_exceptions():
raise
except Exception:
# For other exceptions, use the normal mapping
with map_aiohttp_exceptions():
with _map_stream_read_errors():
raise
finally:
# Release the aiohttp connection when iteration ends for any

View file

@ -0,0 +1,59 @@
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
class TransportErrorKind(Enum):
TIMEOUT = "timeout"
DROPPED_CONNECTION = "dropped_connection"
_ErrorTable: TypeAlias = tuple[tuple[type[BaseException], TransportErrorKind], ...]
_HTTPX_ERRORS: Final[_ErrorTable] = (
(httpx.TimeoutException, TransportErrorKind.TIMEOUT),
(httpx.ReadError, TransportErrorKind.DROPPED_CONNECTION),
(httpx.RemoteProtocolError, TransportErrorKind.DROPPED_CONNECTION),
)
_MAX_CAUSE_DEPTH: Final = 16
def _transport_errors() -> _ErrorTable:
if "httpx2" not in sys.modules:
return _HTTPX_ERRORS
import httpx2
return (
*_HTTPX_ERRORS,
(httpx2.TimeoutException, TransportErrorKind.TIMEOUT),
(httpx2.ReadError, TransportErrorKind.DROPPED_CONNECTION),
(httpx2.RemoteProtocolError, TransportErrorKind.DROPPED_CONNECTION),
)
def transport_error_kind(error: object) -> TransportErrorKind | None:
return next((kind for error_class, kind in _transport_errors() if isinstance(error, error_class)), None)
def transport_error_classes(kind: TransportErrorKind) -> tuple[type[BaseException], ...]:
return tuple(error_class for error_class, error_kind in _transport_errors() if error_kind is kind)
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))
return next((kind for kind in kinds if kind is not None), None)

View file

@ -25,6 +25,7 @@ from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_chunk import ChoiceDelta
from openai.types.completion_usage import CompletionUsage
from pydantic import BaseModel, TypeAdapter
from typing_extensions import ReadOnly, TypedDict
if TYPE_CHECKING:
from aiohttp import ClientSession
@ -192,6 +193,26 @@ class OpenAIError(BaseLLMException):
)
_SDK_READS_ADMIN_KEY_ENV: Final = "admin_api_key" in _OPENAI_INIT_PARAMS
_MISSING_OPENAI_API_KEY_MESSAGE: Final = (
"Missing credentials. Please pass an `api_key` or set the `OPENAI_API_KEY` environment variable."
)
class OpenAISdkKeyParams(TypedDict, total=False):
api_key: ReadOnly[str | None]
admin_api_key: ReadOnly[str]
def openai_sdk_key_params(api_key: str | None) -> OpenAISdkKeyParams:
if not _SDK_READS_ADMIN_KEY_ENV:
return {"api_key": api_key}
sdk_api_key: Final = os.environ.get("OPENAI_API_KEY") if api_key is None else api_key
if not sdk_api_key:
raise openai.OpenAIError(_MISSING_OPENAI_API_KEY_MESSAGE)
return {"api_key": sdk_api_key, "admin_api_key": sdk_api_key}
def upstream_error_response(error: Exception) -> httpx.Response | None:
if not isinstance(error, (openai.APIStatusError, httpx.HTTPStatusError)):
return None

View file

@ -72,6 +72,7 @@ from .common_utils import (
is_openai_backed_api_base,
is_output_token_limit_error,
model_dump_provider_response,
openai_sdk_key_params,
upstream_error_response,
)
from .workload_identity import (
@ -469,7 +470,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
)
if workload_identity_config is not None
else AsyncOpenAI(
api_key=openai_static_api_key(api_key),
**openai_sdk_key_params(openai_static_api_key(api_key)),
base_url=api_base,
http_client=async_http_client,
timeout=timeout,
@ -491,7 +492,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM):
)
if workload_identity_config is not None
else OpenAI(
api_key=openai_static_api_key(api_key),
**openai_sdk_key_params(openai_static_api_key(api_key)),
base_url=api_base,
http_client=sync_http_client,
timeout=timeout,

View file

@ -21,6 +21,7 @@ from .common_utils import (
OpenAIError,
OpenAIHTTPClient,
is_openai_backed_api_base,
openai_sdk_key_params,
)
if TYPE_CHECKING:
@ -185,7 +186,7 @@ def build_openai_client(
None if static_key_http_client_factory is None else static_key_http_client_factory()
)
return OpenAI(
api_key=openai_static_api_key(api_key),
**openai_sdk_key_params(openai_static_api_key(api_key)),
base_url=api_base,
timeout=timeout,
max_retries=retries,
@ -241,7 +242,7 @@ def build_async_openai_client(
None if static_key_http_client_factory is None else static_key_http_client_factory()
)
return AsyncOpenAI(
api_key=openai_static_api_key(api_key),
**openai_sdk_key_params(openai_static_api_key(api_key)),
base_url=api_base,
timeout=timeout,
max_retries=retries,

View file

@ -7019,7 +7019,13 @@ def embedding(
azure_ad_token: Final = optional_params.pop("azure_ad_token", None) or get_secret_str("AZURE_AD_TOKEN")
api_key = api_key or litellm.api_key or litellm.azure_key or get_secret_str("AZURE_API_KEY")
api_key = (
api_key
or litellm.api_key
or litellm.azure_key
or get_secret_str("AZURE_API_KEY")
or get_secret_str("AZURE_OPENAI_API_KEY")
)
if api_base is None:
raise ValueError("No API Base provided for Azure OpenAI LLM provider. Set 'AZURE_API_BASE' in .env")
@ -8681,7 +8687,13 @@ def transcription(
azure_ad_token: Final = kwargs.pop("azure_ad_token", None) or get_secret_str("AZURE_AD_TOKEN")
api_key = api_key or litellm.api_key or litellm.azure_key or get_secret_str("AZURE_API_KEY")
api_key = (
api_key
or litellm.api_key
or litellm.azure_key
or get_secret_str("AZURE_API_KEY")
or get_secret_str("AZURE_OPENAI_API_KEY")
)
optional_params["extra_headers"] = extra_headers

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, TypeAlias, cast
import httpx
import httpx2
import openai
from fastapi import APIRouter, Depends, HTTPException, Request, Response, WebSocket
from fastapi.responses import StreamingResponse
@ -3618,7 +3619,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

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

View file

@ -93,7 +93,7 @@ def _prompt_tokens_details_wrapper(details: object) -> PromptTokensDetailsWrappe
input_details: Final = InputTokensDetails.model_validate(details)
input_fields: Final = decoded_json_object(input_details.model_dump(exclude={"cache_write_tokens_details"}))
return PromptTokensDetailsWrapper(
**input_fields,
**{**input_fields, "cache_write_tokens": input_details.cache_write_tokens},
cache_creation_token_details=_cache_write_lifetimes(input_details.cache_write_tokens_details),
)
return PromptTokensDetailsWrapper(

View file

@ -65,6 +65,7 @@ from openai.types.responses.response_create_params import (
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from openai.types.responses.response_function_web_search import ResponseFunctionWebSearch
from pydantic import (
BaseModel,
ConfigDict,
Discriminator,
Field,
@ -1437,7 +1438,7 @@ class InputTokensDetails(BaseLiteLLMOpenAIResponseObject):
audio_tokens: int | None = None
cached_tokens: int = 0
cached_tokens_details: CachedTokensDetails | None = None
cache_write_tokens: int | None = Field(default=None, exclude_if=_is_none)
cache_write_tokens: int | None = None
cache_write_tokens_details: CacheWriteTokensDetails | None = Field(default=None, exclude_if=_is_none)
image_tokens: int | None = None
text_tokens: int | None = None
@ -1445,6 +1446,13 @@ class InputTokensDetails(BaseLiteLLMOpenAIResponseObject):
model_config = {"extra": "allow"}
@model_serializer(mode="wrap")
def _report_unreported_cache_writes_as_zero( # noqa: ANN202 # annotating it replaces the model's serialization schema
self, handler: SerializerFunctionWrapHandler
):
serialized: Final[Mapping[str, object]] = handler(self)
return {**serialized, "cache_write_tokens": self.cache_write_tokens or 0}
class ResponseAPIUsage(BaseLiteLLMOpenAIResponseObject):
input_tokens: int
@ -1563,6 +1571,24 @@ def replayable_responses_item(
}
def _with_wire_names(value: object, info: FieldSerializationInfo) -> object:
items: Final = _as_json_array(value)
if items is None:
return value
return [
item.model_dump(
mode=info.mode,
by_alias=True,
exclude_unset=info.exclude_unset,
exclude_defaults=info.exclude_defaults,
exclude_none=info.exclude_none,
)
if isinstance(item, BaseModel)
else item
for item in items
]
def replayable_responses_input(value: object, *, drops_set_created_by: bool = False) -> object:
items: Final = _as_json_array(value)
if items is None:
@ -1648,16 +1674,23 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
@field_serializer("output", mode="wrap")
@classmethod
def _serialize_output_as_replayable(
cls, value: builtins.object, handler: SerializerFunctionWrapHandler, _info: FieldSerializationInfo
cls, value: builtins.object, handler: SerializerFunctionWrapHandler, info: FieldSerializationInfo
):
"""
Drop the nulls the SDK types default in but providers never send: created_by on every output item
(OpenAI rejects it when `output` is replayed as `input`), and status/content/encrypted_content on
reasoning items (the OpenAI C# SDK crashes on status=null, issue #16824).
"""
serialized: Final[object] = handler(value)
serialized: Final[object] = handler(_with_wire_names(value, info))
return replayable_responses_input(serialized)
@field_serializer("tools", mode="wrap")
@classmethod
def _serialize_tools_with_wire_names( # noqa: ANN206 # annotating it replaces the model's serialization schema
cls, value: builtins.object, handler: SerializerFunctionWrapHandler, info: FieldSerializationInfo
):
return handler(_with_wire_names(value, info))
@property
def output_text(self) -> str:
"""

View file

@ -306,7 +306,7 @@ ci = [
"blockbuster==1.5.26",
"beautifulsoup4==4.14.3",
"pylint==4.0.9",
"langchain-openai==1.1.14",
"langchain-openai==1.7.2",
"langgraph>=1.2.4,<1.3.0",
"langgraph-prebuilt>=1.1.0,<1.3.0",
"claude-agent-sdk==0.2.165",
@ -386,6 +386,8 @@ constraint-dependencies = [
override-dependencies = [
# a2a-sdk 1.x requires packaging>=24.0; lunary 1.4.x still caps at <24.0.
"packaging>=24.0",
# semantic-router (through 1.1.0) caps openai at <3.0.0, yet its OpenAI encoder for auto router files runs on SDK 3.
"openai>=2.20.0,<4.0.0",
"cryptography>=50.0.0,<51.0",
"mcp>=2.3.0,<3",
]

View file

@ -322,7 +322,7 @@ def assert_compacted(compacted: CompactedResponse, model: str, text: str) -> Non
"encrypted_content": COMPACT_STATE,
}
],
"usage": dict(COMPACT_USAGE),
"usage": {**COMPACT_USAGE, "input_tokens_details": {"cache_write_tokens": 0, "cached_tokens": 0}},
}, compacted.model_dump_json()

View file

@ -0,0 +1,56 @@
import asyncio
import json
import sys
from importlib.metadata import version
from typing import Final
import httpx
from openai import AsyncOpenAI, OpenAI
from openai.types.chat import ChatCompletion
def _summary(response: ChatCompletion) -> dict[str, object]:
choice: Final = response.choices[0]
calls: Final = choice.message.tool_calls or ()
return {
"id": response.id,
"object": response.object,
"usage": [response.usage.prompt_tokens, response.usage.completion_tokens, response.usage.total_tokens]
if response.usage
else None,
"finish_reason": choice.finish_reason,
"content": choice.message.content,
"tool_calls": [[call.id, call.function.name, json.loads(call.function.arguments)] for call in calls],
}
async def _asynchronous(base_url: str, key: str, parameters: dict[str, object]) -> tuple[ChatCompletion, ...]:
plain: Final = {name: value for name, value in parameters.items() if name != "tools"}
async with AsyncOpenAI(
api_key=key, base_url=base_url, max_retries=0, http_client=httpx.AsyncClient(timeout=10, trust_env=False)
) as client:
return (await client.chat.completions.create(**parameters), await client.chat.completions.create(**plain))
def main(base_url: str, key: str, parameters_json: str) -> None:
parameters: Final = json.loads(parameters_json)
plain: Final = {name: value for name, value in parameters.items() if name != "tools"}
with OpenAI(
api_key=key, base_url=base_url, max_retries=0, http_client=httpx.Client(timeout=10, trust_env=False)
) as client:
sync_tools: Final[ChatCompletion] = client.chat.completions.create(**parameters)
sync_text: Final[ChatCompletion] = client.chat.completions.create(**plain)
async_tools, async_text = asyncio.run(_asynchronous(base_url, key, parameters))
print(
json.dumps(
{
"openai": version("openai"),
"tools": [_summary(sync_tools), _summary(async_tools)],
"text": [_summary(sync_text), _summary(async_text)],
}
)
)
if __name__ == "__main__":
main(*sys.argv[1:])

View file

@ -1,7 +1,9 @@
import json
import os
import subprocess
import uuid
from collections.abc import Callable
from importlib.metadata import version
from pathlib import Path
from typing import Final
import httpx
@ -11,12 +13,22 @@ from integration._support.wire import Reply, Request, wire_server
from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI
@pytest.mark.covers("other.compatibility.openai.retained_client_parses_tools_and_usage")
async def test_retained_openai_clients_parse_real_proxy_tool_and_usage_responses(gateway: Gateway) -> None:
assert version("openai") == "2.33.0", (
"Retain this consumer version independently before upgrading the candidate lock"
)
_RETAINED_OPENAI_VERSION: Final = "2.33.0"
_RETAINED_CONSUMER: Final = Path(__file__).with_name("_retained_openai_consumer.py")
def _retained_openai_python() -> str:
python: Final = os.environ.get("RETAINED_OPENAI_PYTHON")
if not python:
pytest.fail(
f"Set RETAINED_OPENAI_PYTHON to a Python with openai=={_RETAINED_OPENAI_VERSION} installed, "
"as .circleci/scripts/run_integration.sh does for the extensions suite"
)
return python
@pytest.mark.covers("other.compatibility.openai.retained_client_parses_tools_and_usage")
def test_retained_openai_clients_parse_real_proxy_tool_and_usage_responses(gateway: Gateway) -> None:
def provider(request: Request) -> Reply:
assert request.method == "POST" and request.target == "/v1/chat/completions"
body: Final = json.loads(request.body)
@ -72,40 +84,35 @@ async def test_retained_openai_clients_parse_real_proxy_tool_and_usage_responses
],
"extra_body": {"cache": {"no-cache": True}},
}
plain: Final = {name: value for name, value in parameters.items() if name != "tools"}
with OpenAI(
api_key=key,
base_url=str(gateway.client.base_url).rstrip("/") + "/v1",
max_retries=0,
http_client=httpx.Client(timeout=10, trust_env=False),
) as sync:
first: Final = sync.chat.completions.create(**parameters)
first_text: Final = sync.chat.completions.create(**plain)
async with AsyncOpenAI(
api_key=key,
base_url=str(gateway.client.base_url).rstrip("/") + "/v1",
max_retries=0,
http_client=httpx.AsyncClient(timeout=10, trust_env=False),
) as asynchronous:
second: Final = await asynchronous.chat.completions.create(**parameters)
second_text: Final = await asynchronous.chat.completions.create(**plain)
assert len({response.id for response in (first, second, first_text, second_text)}) == 4
for response in (first, second, first_text, second_text):
assert response.object == "chat.completion"
assert (
response.usage.prompt_tokens == 11
and response.usage.completion_tokens == 4
and response.usage.total_tokens == 15
)
for response in (first, second):
assert response.choices[0].finish_reason == "tool_calls"
call: Final = response.choices[0].message.tool_calls[0]
assert call.id == "synthetic-call" and call.function.name == "add"
assert json.loads(call.function.arguments) == {"a": 3, "b": 5}
for response in (first_text, second_text):
assert response.choices[0].finish_reason == "stop"
assert response.choices[0].message.content == "Synthetic answer: 8"
assert not response.choices[0].message.tool_calls
consumer: Final = subprocess.run(
(
_retained_openai_python(),
"-I",
str(_RETAINED_CONSUMER),
str(gateway.client.base_url).rstrip("/") + "/v1",
key,
json.dumps(parameters),
),
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert consumer.returncode == 0, consumer.stderr
observed: Final = json.loads(consumer.stdout)
assert observed["openai"] == _RETAINED_OPENAI_VERSION
responses: Final = (*observed["tools"], *observed["text"])
assert len({response["id"] for response in responses}) == 4
for response in responses:
assert response["object"] == "chat.completion"
assert response["usage"] == [11, 4, 15]
for response in observed["tools"]:
assert response["finish_reason"] == "tool_calls"
assert response["tool_calls"] == [["synthetic-call", "add", {"a": 3, "b": 5}]]
for response in observed["text"]:
assert response["finish_reason"] == "stop"
assert response["content"] == "Synthetic answer: 8"
assert response["tool_calls"] == []
assert len(wire.drain()) == 4

View file

@ -8,6 +8,7 @@ from contextlib import AbstractContextManager, ExitStack, contextmanager, nullco
from dataclasses import dataclass, replace
from itertools import chain
from pathlib import Path
from types import MappingProxyType
from typing import Final
import httpx
@ -353,6 +354,9 @@ def _normalize_chat_caller_stream(events: Sequence[JsonValue]) -> tuple[dict[str
return tuple({**_json_object_value(event), "created": 1} for event in events)
_FUNCTION_CALL_NULL_FIELDS: Final = MappingProxyType({"async": None, "caller": None, "namespace": None})
def _responses_caller_body(body: dict[str, JsonValue], model: str) -> dict[str, JsonValue]:
usage: Final = _json_object_value(body["usage"])
output: Final = JSON_MESSAGES.validate_python(body["output"])
@ -376,7 +380,9 @@ def _responses_caller_body(body: dict[str, JsonValue], model: str) -> dict[str,
"truncation": None,
"user": None,
"store": None,
"output": [{**item, **({"namespace": None} if item.get("type") == "function_call" else {})} for item in output],
"output": [
{**item, **(_FUNCTION_CALL_NULL_FIELDS if item.get("type") == "function_call" else {})} for item in output
],
"usage": {
"input_tokens_details": None,
"output_tokens_details": None,

View file

@ -263,9 +263,9 @@ def test_arize_otel_v2_a5_responses_sync_sdk(gateway: Gateway, tmp_path: Path) -
extra_body={"metadata": {"trace_marker": marker}, "cache": {"no-cache": True}},
)
assert response.id.startswith("resp_"), response
assert _normalize_responses_caller_body(response.model_dump(mode="json", exclude_unset=True)) == (
_responses_caller_response(_responses_response(marker), rig.model)
), response
assert _normalize_responses_caller_body(
response.model_dump(mode="json", exclude_unset=True, by_alias=True)
) == (_responses_caller_response(_responses_response(marker), rig.model)), response
assert (response.status, response.model) == ("completed", rig.model), response
assert (
response.output[0].type,
@ -809,9 +809,9 @@ def _cache_call(
extra_body={"metadata": {"trace_marker": marker}, "cache": {"no-cache": False}},
)
response: Final = raw.parse()
assert _normalize_responses_caller_body(response.model_dump(mode="json", exclude_unset=True)) == (
_responses_caller_response(_responses_response(marker), rig.model)
), response
assert _normalize_responses_caller_body(
response.model_dump(mode="json", exclude_unset=True, by_alias=True)
) == (_responses_caller_response(_responses_response(marker), rig.model)), response
call: Final = response.output[0]
assert call.type == "function_call", response
return (call.call_id, call.name, call.arguments), response.id, raw.headers

View file

@ -179,6 +179,9 @@ def test_responses_compact_routes_forward_complete_request_and_typed_items(gatew
"status": "completed",
"type": "function_call_output",
"created_by": None,
"caller": None,
"name": None,
"namespace": None,
},
],
"parallel_tool_calls": None,
@ -201,6 +204,7 @@ def test_responses_compact_routes_forward_complete_request_and_typed_items(gatew
"image_tokens": None,
"text_tokens": None,
"video_tokens": None,
"cache_write_tokens": 0,
},
"output_tokens": 3,
"output_tokens_details": {
@ -282,7 +286,7 @@ def test_sdk_compact_reaches_the_compact_endpoint_with_its_full_body(gateway: Ga
"output": [output_item],
"usage": {
"input_tokens": 7,
"input_tokens_details": {"cached_tokens": 0},
"input_tokens_details": {"cache_write_tokens": 0, "cached_tokens": 0},
"output_tokens": 3,
"output_tokens_details": {"reasoning_tokens": 0},
"total_tokens": 10,

View file

@ -1116,7 +1116,7 @@ def test_truncated_http_stream_is_an_error_and_next_stream_succeeds() -> None:
try:
if truncated:
with pytest.raises(
litellm.exceptions.MidStreamFallbackError, match="incomplete chunked read"
litellm.exceptions.MidStreamFallbackError, match="OpenAIException - Connection error"
) as failure:
tuple(stream)
assert isinstance(failure.value.original_exception, litellm.APIConnectionError)

View file

@ -20,4 +20,6 @@ from integration.translation.runner import assert_translation
def test_chat_completions_basic_openai_responses(
case: TranslationTestCase, gateway: Gateway, provider: SharedProvider
) -> None:
if case is GPT_5_6_SOL_TEST_CASE:
pytest.skip("BUG: the Responses-to-chat bridge drops access_programs on OpenAI SDK 3")
assert_translation(case, gateway, provider)

View file

@ -581,6 +581,8 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_01H53znxFvBbuHPXNePjEhW5",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01H53znxFvBbuHPXNePjEhW5",
"namespace": None,
"status": "completed",
@ -754,6 +756,8 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_0186wos82AWCLwkPWVeVegg8",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_0186wos82AWCLwkPWVeVegg8",
"namespace": None,
"status": "completed",
@ -877,6 +881,8 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_01QKMW1txMKCm8oSZyEwo7wX",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01QKMW1txMKCm8oSZyEwo7wX",
"namespace": None,
"status": "completed",

View file

@ -814,6 +814,8 @@ GPT_6_LUNA_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_cODuVBuAwDhnb8M0FPGYyS1f",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_0c7fa1cbc998fdea006ad023be1b6c8197ac0d31ccff25b0d4",
"namespace": None,
"status": "completed",
@ -835,6 +837,8 @@ GPT_6_LUNA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -853,6 +857,8 @@ GPT_6_LUNA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,
@ -1077,6 +1083,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_h9vdLDpm7EuHrkHXzdKY0Yb2",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_0895a553a79167f3006ad023b71d648197afe5314dfd20909f",
"namespace": None,
"status": "completed",
@ -1098,6 +1106,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -1116,6 +1126,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,
@ -1341,6 +1353,8 @@ GPT_6_ASTRA_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_PuG8zuJ8TpdNdpQFRquUj6ZL",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_09fa191d4c7fb192006ad023ca9800819586b8ea16baef56cb",
"namespace": None,
"status": "completed",
@ -1362,6 +1376,8 @@ GPT_6_ASTRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -1380,6 +1396,8 @@ GPT_6_ASTRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,
@ -1600,6 +1618,8 @@ GPT_5_6_TERRA_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_IuSjfDiujWaxiYl7MptZoMXp",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_0e1796d6e4004e2f006ad023d26b5081939200dbb9bfa0382d",
"namespace": None,
"status": "completed",
@ -1621,6 +1641,8 @@ GPT_5_6_TERRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -1639,6 +1661,8 @@ GPT_5_6_TERRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,

View file

@ -712,6 +712,8 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_01NiAAsMCJRXggv46Zro96cN",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01NiAAsMCJRXggv46Zro96cN",
"namespace": None,
"status": "completed",
@ -832,6 +834,8 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_01XZZ8P93YHKzXM5XGRZN7hs",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01XZZ8P93YHKzXM5XGRZN7hs",
"namespace": None,
"status": "completed",
@ -841,6 +845,8 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_01MpgANqoXoBcnhnsz81Q4Q7",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01MpgANqoXoBcnhnsz81Q4Q7",
"namespace": None,
"status": "completed",

View file

@ -475,6 +475,8 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "tooluse_l8Pc3cjAkezdGOeQoxs9XG",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_tooluse_l8Pc3cjAkezdGOeQoxs9XG",
"namespace": None,
"status": "completed",
@ -646,6 +648,8 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "tooluse_HZpviLzbdTE4T0GMawHsmi",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_tooluse_HZpviLzbdTE4T0GMawHsmi",
"namespace": None,
"status": "completed",
@ -805,6 +809,8 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "tooluse_ZbXhsiKlmJB473d9A8N5ZI",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_tooluse_ZbXhsiKlmJB473d9A8N5ZI",
"namespace": None,
"status": "completed",
@ -987,6 +993,8 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase(
"call_id": "tooluse_IZcZuTirqaML2t3hxtMt9K",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_tooluse_IZcZuTirqaML2t3hxtMt9K",
"namespace": None,
"status": "completed",

View file

@ -464,6 +464,8 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_bdrk_01Uivu38DoXo46h5UDP3hKz9",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_bdrk_01Uivu38DoXo46h5UDP3hKz9",
"namespace": None,
"status": "completed",
@ -632,6 +634,8 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_bdrk_01FfS5J9LzLxLwq5caHEM4xW",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_bdrk_01FfS5J9LzLxLwq5caHEM4xW",
"namespace": None,
"status": "completed",
@ -641,6 +645,8 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_bdrk_01FTMGZf7C2vzjc2Ux2SJyoU",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_bdrk_01FTMGZf7C2vzjc2Ux2SJyoU",
"namespace": None,
"status": "completed",
@ -789,6 +795,8 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_bdrk_01J5u6tgn3ppV4c2VpmD2xy6",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_bdrk_01J5u6tgn3ppV4c2VpmD2xy6",
"namespace": None,
"status": "completed",
@ -933,6 +941,8 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_bdrk_01Ee5Sz4v3R2bgHS6gQV5m8N",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_bdrk_01Ee5Sz4v3R2bgHS6gQV5m8N",
"namespace": None,
"status": "completed",

View file

@ -733,6 +733,8 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_bdrk_015nyupS1RboZpDnHW42rzbD",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_bdrk_015nyupS1RboZpDnHW42rzbD",
"namespace": None,
"status": "completed",
@ -908,6 +910,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_5f196ff4cb915362abef090168884770",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_39bb6f400cd35d1fbad36e6cc2fdfaa0",
"namespace": None,
"status": "completed",
@ -929,6 +933,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -947,6 +953,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,
@ -1090,6 +1098,8 @@ GPT_6_LUNA_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_90b43b6346d45db185a8230ca05b7379",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_c4c1831a0bfe5c72b8ab072464830a14",
"namespace": None,
"status": "completed",
@ -1111,6 +1121,8 @@ GPT_6_LUNA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -1129,6 +1141,8 @@ GPT_6_LUNA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,
@ -1272,6 +1286,8 @@ GPT_6_ASTRA_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_38b08e80239e571e8f918025be1a128c",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_13ce596fb38a5b50a123a6e2357705bf",
"namespace": None,
"status": "completed",
@ -1293,6 +1309,8 @@ GPT_6_ASTRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -1311,6 +1329,8 @@ GPT_6_ASTRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,
@ -1454,6 +1474,8 @@ GPT_5_6_TERRA_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_fdd25b3193de5afb9ffba7c630ae90f2",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_db3ddf54911b51569ac9c6a05f31385a",
"namespace": None,
"status": "completed",
@ -1475,6 +1497,8 @@ GPT_5_6_TERRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -1493,6 +1517,8 @@ GPT_5_6_TERRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,

View file

@ -156,6 +156,8 @@ GEMINI_3_8_FLASH_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_23592__thought__" + GEMINI_3_8_FLASH_THOUGHT_SIGNATURE,
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_call_23592__thought__" + GEMINI_3_8_FLASH_THOUGHT_SIGNATURE,
"namespace": None,
"status": "completed",
@ -173,6 +175,7 @@ GEMINI_3_8_FLASH_TEST_CASE: Final = TranslationTestCase(
"image_tokens": None,
"text_tokens": 354,
"video_tokens": None,
"cache_write_tokens": 0,
},
"output_tokens": 396,
"output_tokens_details": {
@ -302,6 +305,8 @@ GEMINI_3_1_PRO_PREVIEW_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_25410__thought__" + GEMINI_3_1_PRO_PREVIEW_THOUGHT_SIGNATURE,
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_call_25410__thought__" + GEMINI_3_1_PRO_PREVIEW_THOUGHT_SIGNATURE,
"namespace": None,
"status": "completed",
@ -319,6 +324,7 @@ GEMINI_3_1_PRO_PREVIEW_TEST_CASE: Final = TranslationTestCase(
"image_tokens": None,
"text_tokens": 354,
"video_tokens": None,
"cache_write_tokens": 0,
},
"output_tokens": 75,
"output_tokens_details": {
@ -467,6 +473,8 @@ GEMINI_4_ARGON_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_31641__thought__" + GEMINI_4_ARGON_THOUGHT_SIGNATURE,
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_call_31641__thought__" + GEMINI_4_ARGON_THOUGHT_SIGNATURE,
"namespace": None,
"status": "completed",
@ -484,6 +492,7 @@ GEMINI_4_ARGON_TEST_CASE: Final = TranslationTestCase(
"image_tokens": None,
"text_tokens": 412,
"video_tokens": None,
"cache_write_tokens": 0,
},
"output_tokens": 202,
"output_tokens_details": {
@ -577,6 +586,7 @@ GEMINI_3_5_FLASH_TEST_CASE: Final = TranslationTestCase(
"image_tokens": None,
"text_tokens": 10,
"video_tokens": None,
"cache_write_tokens": 0,
},
"output_tokens": 127,
"output_tokens_details": {"audio_tokens": None, "reasoning_tokens": 125, "text_tokens": 2},

View file

@ -564,6 +564,8 @@ GPT_6_LUNA_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_YeruxIVkbMog0O7xEjBn8luk",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_042cbd1ba73eb559006ad023a20c5487d09f05bf43be3ac720",
"namespace": None,
"status": "completed",
@ -585,6 +587,8 @@ GPT_6_LUNA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -603,6 +607,8 @@ GPT_6_LUNA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,
@ -703,6 +709,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_S4WtfrQwa6ZLEUZf1BVtA8ON",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_0207c20ae125feee006ad023ae893c87d08068561e36e665ad",
"namespace": None,
"status": "completed",
@ -724,6 +732,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -742,6 +752,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,
@ -885,6 +897,8 @@ GPT_6_ASTRA_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_wVrKXoGNLx7ClNV8cmG0E8pP",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_0552d3d37f56f8e8006ad02397444087d09ede3b901e93cb70",
"namespace": None,
"status": "completed",
@ -906,6 +920,8 @@ GPT_6_ASTRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -924,6 +940,8 @@ GPT_6_ASTRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,
@ -1068,6 +1086,8 @@ GPT_5_6_TERRA_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_FI1PXRT2XNjueqLOd8GliYTF",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_01818bf6930dd01f006ad0238b33dc87d0866bf763c4861a74",
"namespace": None,
"status": "completed",
@ -1089,6 +1109,8 @@ GPT_5_6_TERRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -1107,6 +1129,8 @@ GPT_5_6_TERRA_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,

View file

@ -208,6 +208,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
"call_id": "call_S4WtfrQwa6ZLEUZf1BVtA8ON",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_0207c20ae125feee006ad023ae893c87d08068561e36e665ad",
"namespace": None,
"status": "completed",
@ -229,6 +231,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
"output_schema": None,
@ -247,6 +251,8 @@ GPT_6_1_SOL_TEST_CASE: Final = TranslationTestCase(
},
"strict": True,
"type": "function",
"allowed_callers": None,
"async": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
"output_schema": None,

View file

@ -472,6 +472,7 @@ GEMINI_3_5_FLASH_TEST_CASE: Final = TranslationTestCase(
"image_tokens": None,
"text_tokens": 9,
"video_tokens": None,
"cache_write_tokens": 0,
},
"output_tokens": 86,
"output_tokens_details": {"audio_tokens": None, "reasoning_tokens": 84, "text_tokens": 2},
@ -735,6 +736,8 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_vrtx_014c2eZQXGiRtzh9KvDXRhRh",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_vrtx_014c2eZQXGiRtzh9KvDXRhRh",
"namespace": None,
"status": "completed",
@ -744,6 +747,8 @@ CLAUDE_SONNET_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_vrtx_01VoiC4J2kb7yivWHYGqvRRm",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_vrtx_01VoiC4J2kb7yivWHYGqvRRm",
"namespace": None,
"status": "completed",
@ -906,6 +911,8 @@ CLAUDE_HAIKU_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_vrtx_01Jxxsojf34b9skuGH47XkJd",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_vrtx_01Jxxsojf34b9skuGH47XkJd",
"namespace": None,
"status": "completed",
@ -1055,6 +1062,8 @@ CLAUDE_OPUS_5_5_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_vrtx_01GFi5nAbXNTTfSxopXwNNPp",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_vrtx_01GFi5nAbXNTTfSxopXwNNPp",
"namespace": None,
"status": "completed",
@ -1218,6 +1227,8 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_vrtx_01WCXdQndfe72gQeCatYYmiP",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_vrtx_01WCXdQndfe72gQeCatYYmiP",
"namespace": None,
"status": "completed",
@ -1227,6 +1238,8 @@ CLAUDE_FABLE_5_1_TEST_CASE: Final = TranslationTestCase(
"call_id": "toolu_vrtx_01MDyzK8nTHNuCpY7jS6YJ2Y",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_vrtx_01MDyzK8nTHNuCpY7jS6YJ2Y",
"namespace": None,
"status": "completed",
@ -1428,6 +1441,8 @@ GEMINI_3_8_FLASH_TEST_CASE: Final = TranslationTestCase(
),
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": (
"fc_call_316157__thought__AY89a18zqWYsP1fCe9VRYni0L9f03IJNDHvm1boNA4Q2dVNJPzZZvrxcHGuC7kCwx5e9kBdKsGE"
"Q9/Nr4AjO5c6ukC2h2NEeTinmhak8IRJNBkDQ7OZ7at4q5KYoqYfDZ8aNohtXWBxVdoSK9YZHp+nPGYE7zmXk1icksF+WJG0ga0j"
@ -1479,6 +1494,7 @@ GEMINI_3_8_FLASH_TEST_CASE: Final = TranslationTestCase(
"image_tokens": None,
"text_tokens": 242,
"video_tokens": None,
"cache_write_tokens": 0,
},
"output_tokens": 329,
"output_tokens_details": {"audio_tokens": None, "reasoning_tokens": 265, "text_tokens": 64},
@ -1636,6 +1652,8 @@ GEMINI_3_1_PRO_PREVIEW_TEST_CASE: Final = TranslationTestCase(
),
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": (
"fc_call_226688__thought__AY89a18mH4gPpBuF8sZq3BxEt7fVfeky9tDk6WrFiAYqUNfy1TwKL5UEuc60FQFzZzoY6BhtmNY"
"M/YnvWntgJG06tpD+R69L+px8wJWN2jf96nYOxDNe5xM3Z6yz7QO1XikWun/bqStP3GVvN2xCTRBeQxkN0n5+NPDWYXC94zo2uik"
@ -1677,6 +1695,7 @@ GEMINI_3_1_PRO_PREVIEW_TEST_CASE: Final = TranslationTestCase(
"image_tokens": None,
"text_tokens": 242,
"video_tokens": None,
"cache_write_tokens": 0,
},
"output_tokens": 241,
"output_tokens_details": {"audio_tokens": None, "reasoning_tokens": 171, "text_tokens": 70},
@ -1816,6 +1835,8 @@ GEMINI_4_ARGON_TEST_CASE: Final = TranslationTestCase(
),
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": (
"fc_call_268046__thought__AY89a180ZmYN6Zq/ICj0BCu+eBS75TWGlATeX2fsIhoKaFuGLfTjmizARO/YOhSzt4TK8d1jl7s"
"kKbDq23TdvRVOJfjPsI85UiVCF8DZC4gSdHQbTXiDtBUsue7BxMBnoNfPJe34Y1p6WqjbaK44zsfJybZVsWJSmQOW5Ky8qnBZXfE"
@ -1849,6 +1870,7 @@ GEMINI_4_ARGON_TEST_CASE: Final = TranslationTestCase(
"image_tokens": None,
"text_tokens": 412,
"video_tokens": None,
"cache_write_tokens": 0,
},
"output_tokens": 161,
"output_tokens_details": {"audio_tokens": None, "reasoning_tokens": 92, "text_tokens": 69},

View file

@ -140,6 +140,8 @@ CLAUDE_SONNET_5_5_CACHE_HIT_TEST_CASE: Final = replace(
"call_id": "toolu_01RoPyymXeGn7TS6uLnKMkzo",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01RoPyymXeGn7TS6uLnKMkzo",
"namespace": None,
"status": "completed",
@ -149,6 +151,8 @@ CLAUDE_SONNET_5_5_CACHE_HIT_TEST_CASE: Final = replace(
"call_id": "toolu_01Cv9tx97CJhxergPDGZLTxC",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01Cv9tx97CJhxergPDGZLTxC",
"namespace": None,
"status": "completed",

View file

@ -89,6 +89,8 @@ CLAUDE_HAIKU_5_5_CACHE_HIT_TEST_CASE: Final = replace(
"call_id": "toolu_01MnZE6CSPdC7bHqEiAaKehz",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01MnZE6CSPdC7bHqEiAaKehz",
"namespace": None,
"status": "completed",
@ -173,6 +175,8 @@ CLAUDE_FABLE_5_1_CACHE_HIT_TEST_CASE: Final = replace(
"call_id": "toolu_01SamuxVRxpP9kLotKTnYMfn",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01SamuxVRxpP9kLotKTnYMfn",
"namespace": None,
"status": "completed",

View file

@ -304,6 +304,8 @@ GEMINI_3_8_FLASH_THOUGHTS_OFF_TEST_CASE: Final = replace(
"call_id": "call_584229__thought__" + GEMINI_3_8_FLASH_THOUGHTS_OFF_THOUGHT_SIGNATURE,
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_call_584229__thought__" + GEMINI_3_8_FLASH_THOUGHTS_OFF_THOUGHT_SIGNATURE,
"namespace": None,
"status": "completed",
@ -319,6 +321,7 @@ GEMINI_3_8_FLASH_THOUGHTS_OFF_TEST_CASE: Final = replace(
"image_tokens": None,
"text_tokens": 354,
"video_tokens": None,
"cache_write_tokens": 0,
},
"output_tokens": 436,
"output_tokens_details": {"audio_tokens": None, "reasoning_tokens": 366, "text_tokens": 70},

View file

@ -180,6 +180,8 @@ RESPONSES_RESPONSE: Final[Mapping[str, JsonValue]] = {
"call_id": "toolu_01GYseiK8hh9QxfWJ6HbiAt3",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01GYseiK8hh9QxfWJ6HbiAt3",
"namespace": None,
"status": "completed",
@ -189,6 +191,8 @@ RESPONSES_RESPONSE: Final[Mapping[str, JsonValue]] = {
"call_id": "toolu_01U3TuUTifkSBLVzyTFnbMN1",
"name": "read_file",
"type": "function_call",
"async": None,
"caller": None,
"id": "fc_toolu_01U3TuUTifkSBLVzyTFnbMN1",
"namespace": None,
"status": "completed",
@ -211,6 +215,9 @@ RESPONSES_RESPONSE: Final[Mapping[str, JsonValue]] = {
},
"strict": None,
"type": "function",
"allowed_callers": None,
"async": None,
"output_schema": None,
"defer_loading": None,
"description": "Search source and test files for a literal term.",
},
@ -228,6 +235,9 @@ RESPONSES_RESPONSE: Final[Mapping[str, JsonValue]] = {
},
"strict": None,
"type": "function",
"allowed_callers": None,
"async": None,
"output_schema": None,
"defer_loading": None,
"description": "Read a range of lines from a project file.",
},

View file

@ -1075,6 +1075,48 @@ def test_streamed_tool_search_turn_replays_with_its_reasoning_item():
assert request["input"][5]["namespace"] == "get_weather"
def test_typed_tool_search_output_keeps_the_wire_name_of_async_for_the_replay() -> None:
tool_search_output: Final = {
"type": "tool_search_output",
"id": "tso_1",
"execution": "server",
"status": "completed",
"tools": [{"type": "function", "name": "get_weather", "parameters": {}, "strict": True, "async": True}],
}
answer: Final = ResponsesAPIResponse(id="resp_1", created_at=1, output=[tool_search_output])
message: Final = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices(answer.output)[0].message
assert message.provider_specific_fields == {"tool_search_items": [tool_search_output]}
def test_streamed_tool_search_output_replays_only_the_tool_fields_upstream_sent() -> None:
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
from litellm.types.llms.openai import ResponseCompletedEvent, ResponsesAPIStreamEvents
tool_search_output: Final = {
"type": "tool_search_output",
"id": "tso_1",
"execution": "server",
"status": "completed",
"tools": [
{"type": "function", "name": "get_weather", "parameters": {}, "strict": None, "defer_loading": True},
{"type": "namespace", "name": "crm", "description": "CRM", "tools": [{"type": "function", "name": "find"}]},
],
}
completed: Final = ResponseCompletedEvent(
type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
response=ResponsesAPIResponse(id="resp_1", created_at=1, status="completed", output=[tool_search_output]),
)
iterator: Final = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True)
chunk: Final = iterator._handle_string_chunk(completed)
assert chunk.choices[0].delta.provider_specific_fields == {"tool_search_items": [tool_search_output]}
@pytest.mark.parametrize(
"answer_item",
[

View file

@ -2,10 +2,13 @@ import asyncio
import base64
import importlib
import os
import sys
import shutil
import tempfile
from collections.abc import AsyncIterator, Callable, Container, Coroutine, Iterable, Iterator
from dataclasses import dataclass, field
from functools import partial
from importlib.metadata import version
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, Literal, TypeAlias
@ -584,6 +587,35 @@ def fake_provider_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(name, value)
@pytest.fixture
def openai_token_exchange_via_httpx(monkeypatch: pytest.MonkeyPatch) -> None:
if int(version("openai").split(".")[0]) < 3:
return
import httpx2
import openai.auth._workload as workload_module
native_client_factory: Final = workload_module.DefaultHttpx2Client
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

@ -19,6 +19,7 @@ from litellm.litellm_core_utils.audio_utils.transcription_streaming import (
litellm_exception_mapper,
wrap_transcription_stream,
)
from litellm.llms.custom_httpx.transport_errors import TransportErrorKind, chained_transport_error_kind
from litellm.types.utils import TranscriptionResponse
_STARTED_AT: Final = datetime.datetime(2026, 10, 28, 12, 0, 0) # noqa: DTZ001 # callback timestamps use the legacy naive contract
@ -177,10 +178,12 @@ async def test_a_stream_the_upstream_drops_mid_transcript_raises_a_litellm_error
model="openai/gpt-transcribe", file=("sample.wav", b"audio"), stream=True, client=upstream
)
with pytest.raises(litellm.APIConnectionError, match="peer closed connection"):
with pytest.raises(litellm.APIConnectionError) as raised:
async for _ in stream:
pass
assert chained_transport_error_kind(raised.value) is TransportErrorKind.DROPPED_CONNECTION
class _FailureHookCounter(CustomLogger):
def __init__(self) -> None:

View file

@ -5,6 +5,7 @@ from typing import Any, Final, Literal, Optional, Union
from unittest.mock import MagicMock, patch
import httpx
import httpx2
import openai
import pytest
from fastapi import HTTPException
@ -2566,10 +2567,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
@ -3209,3 +3208,33 @@ def test_openai_error_matched_by_its_message_keeps_the_upstream_body(
assert error_response.headers["x-request-id"] == "req-openai"
assert "sk-openai-secret" not in str(error_response.request.headers)
assert error_response.request.content == b""
@pytest.mark.parametrize("custom_llm_provider", ("openai", "azure"))
def test_sdk_error_on_httpx2_keeps_the_upstream_body_without_the_provider_credentials(
custom_llm_provider: str,
) -> None:
body: Final = json.dumps(
{"error": {"message": "Unsupported value: 'temperature'", "type": "invalid_request_error", "code": None}}
)
sent_request: Final = httpx2.Request(
"POST",
"https://resource.openai.azure.com/openai/v1/chat/completions?api-key=sdk-query-secret",
headers={"authorization": "Bearer sk-openai-secret"},
content=b'{"messages":[{"role":"user","content":"hi"}]}',
)
upstream: Final = httpx2.Response(400, content=body, headers={"x-request-id": "req-sdk3"}, request=sent_request)
with pytest.raises(litellm.BadRequestError) as raised:
exception_type(
model="gpt-5.6",
original_exception=openai.BadRequestError(
"Unsupported value: 'temperature'", response=upstream, body=json.loads(body)
),
custom_llm_provider=custom_llm_provider,
)
assert raised.value.response.text == body
assert raised.value.response.headers["x-request-id"] == "req-sdk3"
assert str(raised.value.response.request.url) == "https://resource.openai.azure.com/openai/v1/chat/completions"
assert "sk-openai-secret" not in str(raised.value.response.request.headers)

View file

@ -17,8 +17,10 @@ from typing import AsyncIterator, Callable, Final, Iterator, Literal, NoReturn,
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import openai
import pytest
from openai import AsyncOpenAI, AsyncStream, OpenAI, Stream
from openai.types import Completion, CompletionChoice
from openai.types.chat import ChatCompletionChunk
from pydantic import BaseModel, TypeAdapter
import litellm
@ -39,7 +41,9 @@ from litellm.litellm_core_utils.streaming_handler import (
)
from litellm.llms.anthropic.chat.handler import ModelResponseIterator as AnthropicModelResponseIterator
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.fireworks_ai.chat.transformation import FireworksAIChatCompletionStreamingHandler
from litellm.llms.hosted_vllm.chat.transformation import HostedVLLMChatConfig
from litellm.llms.ollama.completion.transformation import OllamaTextCompletionResponseIterator
from litellm.llms.openai.chat.gpt_transformation import OpenAIChatCompletionStreamingHandler
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator as GeminiModelResponseIterator,
@ -49,6 +53,7 @@ from litellm.types.utils import (
ChoiceLogprobs,
CompletionTokensDetailsWrapper,
Delta,
GenericStreamingChunk,
ModelResponse,
ModelResponseStream,
PromptTokensDetailsWrapper,
@ -5721,7 +5726,9 @@ class TransportErrorAfterChunksIterator:
raise StopAsyncIteration
def _reset_test_chunk(content: Optional[str] = None, finish_reason: Optional[str] = None) -> ModelResponseStream:
def _reset_test_chunk(
content: Optional[str] = None, finish_reason: Optional[str] = None, index: int = 0
) -> ModelResponseStream:
return ModelResponseStream(
id="chatcmpl-reset-test",
created=1783458104,
@ -5729,7 +5736,7 @@ def _reset_test_chunk(content: Optional[str] = None, finish_reason: Optional[str
object="chat.completion.chunk",
choices=[
StreamingChoices(
index=0,
index=index,
delta=Delta(content=content),
finish_reason=finish_reason,
)
@ -5813,6 +5820,446 @@ async def test_transport_read_error_before_finish_reason_raises(logging_obj: Log
assert fabricated_finish_reasons == []
_SDK3_CHAT_REQUEST_URL: Final = "https://api.openai.com/v1/chat/completions"
def _raised_from(error: Exception, cause: Exception) -> Exception:
try:
raise error from cause
except Exception as raised:
return raised
def _httpx2_read_error() -> Exception:
return pytest.importorskip("httpx2").ReadError("peer closed connection")
def _httpx2_protocol_error() -> Exception:
return pytest.importorskip("httpx2").RemoteProtocolError("peer closed connection without sending complete body")
def _sdk_wrapped_read_error() -> Exception:
httpx2: Final = pytest.importorskip("httpx2")
return _raised_from(
openai.APIConnectionError(request=httpx2.Request("POST", _SDK3_CHAT_REQUEST_URL)),
httpx2.ReadError("peer closed connection"),
)
def _httpx2_read_timeout() -> Exception:
return pytest.importorskip("httpx2").ReadTimeout("timed out")
def _sdk_wrapped_read_timeout() -> Exception:
httpx2: Final = pytest.importorskip("httpx2")
return _raised_from(
openai.APITimeoutError(request=httpx2.Request("POST", _SDK3_CHAT_REQUEST_URL)),
httpx2.ReadTimeout("timed out"),
)
_SDK3_DROPPED_CONNECTIONS: Final = pytest.mark.parametrize(
"dropped_connection",
(_httpx2_read_error, _httpx2_protocol_error, _sdk_wrapped_read_error),
ids=("httpx2_read_error", "httpx2_protocol_error", "sdk_wrapped_read_error"),
)
_SYNC_AND_ASYNC: Final = pytest.mark.parametrize("sync_mode", (True, False), ids=("sync", "async"))
def _chunks_then_failure(chunks: Sequence[ModelResponseStream], failure: Exception) -> Iterator[ModelResponseStream]:
yield from chunks
raise failure
def _openai_stream_over(
chunks: Sequence[ModelResponseStream], failure: Exception, sync_mode: bool, logging_obj: Logging
) -> CustomStreamWrapper:
stream: Final = _chunks_then_failure(chunks, failure)
return CustomStreamWrapper(
completion_stream=stream if sync_mode else _as_async(stream),
model="openai/stub-model",
custom_llm_provider="openai",
logging_obj=logging_obj,
)
async def _collect(wrapper: CustomStreamWrapper, sync_mode: bool) -> list[ModelResponseStream]:
if sync_mode:
return list(wrapper)
return [chunk async for chunk in wrapper]
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
@_SDK3_DROPPED_CONNECTIONS
async def test_sdk3_dropped_connection_after_finish_reason_ends_the_stream_cleanly(
logging_obj: Logging, sync_mode: bool, dropped_connection: Callable[[], Exception]
):
wrapper: Final = _openai_stream_over(
(_reset_test_chunk(content="Hello"), _reset_test_chunk(finish_reason="stop")),
dropped_connection(),
sync_mode,
logging_obj,
)
chunks: Final = await _collect(wrapper, sync_mode)
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == "Hello"
assert [chunk.choices[0].finish_reason for chunk in chunks if chunk.choices[0].finish_reason] == ["stop"]
def _two_choice_openai_stream(
finished_choices: Sequence[int], sync_mode: bool, logging_obj: Logging
) -> CustomStreamWrapper:
logging_obj.update_environment_variables(litellm_params={}, optional_params={"n": 2})
return _openai_stream_over(
(
_reset_test_chunk(content="Hi", index=0),
_reset_test_chunk(content="Hel", index=1),
*(_reset_test_chunk(finish_reason="stop", index=index) for index in finished_choices),
),
_httpx2_read_error(),
sync_mode,
logging_obj,
)
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
async def test_sdk3_dropped_connection_while_another_requested_choice_streams_raises(
logging_obj: Logging, sync_mode: bool
):
wrapper: Final = _two_choice_openai_stream((0,), sync_mode, logging_obj)
with pytest.raises(MidStreamFallbackError) as excinfo:
await _collect(wrapper, sync_mode)
assert isinstance(excinfo.value.original_exception, litellm.APIConnectionError)
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
async def test_sdk3_dropped_connection_after_every_requested_choice_finished_ends_cleanly(
logging_obj: Logging, sync_mode: bool
):
wrapper: Final = _two_choice_openai_stream((0, 1), sync_mode, logging_obj)
chunks: Final = await _collect(wrapper, sync_mode)
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == "HiHel"
def _text_completion_chunk(index: int, text: str = "", finish_reason: Optional[str] = None) -> Completion:
return Completion.model_construct(
id="cmpl-two-choices",
object="text_completion",
created=1,
model="gpt-3.5-turbo-instruct",
choices=[CompletionChoice.model_construct(index=index, text=text, finish_reason=finish_reason, logprobs=None)],
)
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
@pytest.mark.parametrize("provider", ("text-completion-openai", "azure_text"))
@pytest.mark.parametrize(
("finished_choices", "ends_cleanly"), (((0, 1), True), ((1,), False)), ids=("both_finished", "choice_0_open")
)
async def test_two_choice_text_completion_stream_dropped_before_done_ends_cleanly_only_once_both_finish(
logging_obj: Logging, sync_mode: bool, provider: str, finished_choices: Sequence[int], ends_cleanly: bool
):
logging_obj.update_environment_variables(litellm_params={}, optional_params={"n": 2})
stream: Final = _chunks_then_failure(
(
_text_completion_chunk(0, "Hi"),
_text_completion_chunk(1, "Hel"),
*(_text_completion_chunk(index, finish_reason="stop") for index in finished_choices),
),
_httpx2_read_error(),
)
wrapper: Final = CustomStreamWrapper(
completion_stream=stream if sync_mode else _as_async(stream),
model="gpt-3.5-turbo-instruct",
custom_llm_provider=provider,
logging_obj=logging_obj,
)
if not ends_cleanly:
with pytest.raises(MidStreamFallbackError):
await _collect(wrapper, sync_mode)
return
chunks: Final = await _collect(wrapper, sync_mode)
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == "HiHel"
def _chat_choices_finishing_in_one_chunk() -> tuple[ModelResponseStream, ...]:
return (
_reset_test_chunk(content="Hi", index=0),
_reset_test_chunk(content="Hel", index=1),
ModelResponseStream(
id="chatcmpl-reset-test",
created=1783458104,
model="stub-model",
object="chat.completion.chunk",
choices=[
StreamingChoices(index=0, delta=Delta(content=None), finish_reason="stop"),
StreamingChoices(index=1, delta=Delta(content=None), finish_reason="stop"),
],
),
)
def _text_choices_finishing_in_one_chunk() -> tuple[Completion, ...]:
return (
_text_completion_chunk(0, "Hi"),
_text_completion_chunk(1, "Hel"),
Completion.model_construct(
id="cmpl-two-choices",
object="text_completion",
created=1,
model="gpt-3.5-turbo-instruct",
choices=[
CompletionChoice.model_construct(index=0, text="", finish_reason="stop", logprobs=None),
CompletionChoice.model_construct(index=1, text="", finish_reason="stop", logprobs=None),
],
),
)
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
@pytest.mark.parametrize(
("provider", "chunks"),
(("openai", _chat_choices_finishing_in_one_chunk), ("text-completion-openai", _text_choices_finishing_in_one_chunk)),
ids=("chat", "text_completion"),
)
async def test_one_chunk_finishing_both_requested_choices_then_dropped_before_done_ends_cleanly(
logging_obj: Logging,
sync_mode: bool,
provider: str,
chunks: Callable[[], Sequence[ModelResponseStream | Completion]],
):
logging_obj.update_environment_variables(litellm_params={}, optional_params={"n": 2})
stream: Final = _chunks_then_failure(chunks(), _httpx2_read_error())
wrapper: Final = CustomStreamWrapper(
completion_stream=stream if sync_mode else _as_async(stream),
model="stub-model",
custom_llm_provider=provider,
logging_obj=logging_obj,
)
collected: Final = await _collect(wrapper, sync_mode)
assert "".join(chunk.choices[0].delta.content or "" for chunk in collected if chunk.choices) == "HiHel"
def _two_choices_requested_but_only_choice_0_streamed() -> tuple[ModelResponseStream, ...]:
return (_reset_test_chunk(content="Hi", index=0), _reset_test_chunk(finish_reason="stop", index=0))
def _two_text_choices_requested_but_only_choice_0_streamed() -> tuple[Completion, ...]:
return (_text_completion_chunk(0, "Hi"), _text_completion_chunk(0, finish_reason="stop"))
def _choice_1_finishing_beside_choice_0_content() -> tuple[ModelResponseStream, ...]:
return (
_reset_test_chunk(content="Hel", index=1),
ModelResponseStream(
id="chatcmpl-reset-test",
created=1783458104,
model="stub-model",
object="chat.completion.chunk",
choices=[
StreamingChoices(index=0, delta=Delta(content="Hi"), finish_reason=None),
StreamingChoices(index=1, delta=Delta(content=None), finish_reason="stop"),
],
),
_reset_test_chunk(finish_reason="stop", index=0),
)
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
@pytest.mark.parametrize(
("provider", "chunks", "content"),
(
("openai", _two_choices_requested_but_only_choice_0_streamed, "Hi"),
("text-completion-openai", _two_text_choices_requested_but_only_choice_0_streamed, "Hi"),
("openai", _choice_1_finishing_beside_choice_0_content, "HelHi"),
),
ids=("provider_streams_fewer_choices", "text_provider_streams_fewer_choices", "choice_1_finishes_mid_chunk"),
)
async def test_dropped_before_done_after_every_streamed_choice_finished_ends_cleanly(
logging_obj: Logging,
sync_mode: bool,
provider: str,
chunks: Callable[[], Sequence[ModelResponseStream | Completion]],
content: str,
):
logging_obj.update_environment_variables(litellm_params={}, optional_params={"n": 2})
stream: Final = _chunks_then_failure(chunks(), _httpx2_read_error())
wrapper: Final = CustomStreamWrapper(
completion_stream=stream if sync_mode else _as_async(stream),
model="stub-model",
custom_llm_provider=provider,
logging_obj=logging_obj,
)
collected: Final = await _collect(wrapper, sync_mode)
assert "".join(chunk.choices[0].delta.content or "" for chunk in collected if chunk.choices) == content
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
async def test_dropped_before_done_while_choice_0_streams_beside_a_finished_choice_1_raises(
logging_obj: Logging, sync_mode: bool
):
logging_obj.update_environment_variables(litellm_params={}, optional_params={"n": 2})
wrapper: Final = _openai_stream_over(
_choice_1_finishing_beside_choice_0_content()[:2], _httpx2_read_error(), sync_mode, logging_obj
)
with pytest.raises(MidStreamFallbackError):
await _collect(wrapper, sync_mode)
def _ollama_generate_lines_through_done_then(failure: Exception) -> Iterator[str]:
yield json.dumps({"model": "smollm2:135m", "response": "Hi", "done": False})
yield json.dumps(
{
"model": "smollm2:135m",
"response": "",
"done": True,
"done_reason": "stop",
"prompt_eval_count": 3,
"eval_count": 1,
}
)
raise failure
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
async def test_ollama_generate_stream_dropped_after_its_done_line_ends_cleanly(logging_obj: Logging, sync_mode: bool):
lines: Final = _ollama_generate_lines_through_done_then(_httpx2_read_error())
wrapper: Final = CustomStreamWrapper(
completion_stream=OllamaTextCompletionResponseIterator(
streaming_response=lines if sync_mode else _as_async(lines), sync_stream=sync_mode
),
model="smollm2:135m",
custom_llm_provider="ollama",
logging_obj=logging_obj,
)
collected: Final = await _collect(wrapper, sync_mode)
assert "".join(chunk.choices[0].delta.content or "" for chunk in collected if chunk.choices) == "Hi"
assert [chunk.choices[0].finish_reason for chunk in collected if chunk.choices[0].finish_reason] == ["stop"]
def _fireworks_sse_line_for_choice(index: int, content: str) -> str:
chunk: Final = {
"id": "chatcmpl-two-choices",
"object": "chat.completion.chunk",
"created": 1,
"model": "accounts/fireworks/models/deepseek-v4p1-flash",
"choices": [{"index": index, "delta": {"role": "assistant", "content": content}, "finish_reason": None}],
}
return f"data: {json.dumps(chunk)}"
def _fireworks_n2_lines_without_finish_reasons_through_done_then(failure: Exception) -> Iterator[str]:
yield _fireworks_sse_line_for_choice(0, "Hi")
yield _fireworks_sse_line_for_choice(1, "Hel")
yield "data: [DONE]"
raise failure
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
async def test_n2_stream_dropped_after_done_without_choice_finish_reasons_ends_cleanly(
logging_obj: Logging, sync_mode: bool
):
logging_obj.update_environment_variables(litellm_params={}, optional_params={"n": 2})
lines: Final = _fireworks_n2_lines_without_finish_reasons_through_done_then(_httpx2_read_error())
wrapper: Final = CustomStreamWrapper(
completion_stream=FireworksAIChatCompletionStreamingHandler(
streaming_response=lines if sync_mode else _as_async(lines), sync_stream=sync_mode
),
model="accounts/fireworks/models/deepseek-v4p1-flash",
custom_llm_provider="fireworks_ai",
logging_obj=logging_obj,
)
collected: Final = await _collect(wrapper, sync_mode)
assert "".join(chunk.choices[0].delta.content or "" for chunk in collected if chunk.choices) == "HiHel"
def _generic_chunks_with_a_second_content_block_then(failure: Exception) -> Iterator[GenericStreamingChunk]:
yield GenericStreamingChunk(text="Hi", is_finished=False, finish_reason="", usage=None, index=0)
yield GenericStreamingChunk(text="!", is_finished=False, finish_reason="", usage=None, index=1)
yield GenericStreamingChunk(text="", is_finished=True, finish_reason="stop", usage=None, index=0)
raise failure
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
async def test_generic_chunk_index_is_not_a_choice_when_the_stream_drops_after_its_finish(
logging_obj: Logging, sync_mode: bool
):
chunks: Final = _generic_chunks_with_a_second_content_block_then(_httpx2_read_error())
wrapper: Final = CustomStreamWrapper(
completion_stream=chunks if sync_mode else _as_async(chunks),
model="command-a-03-2025",
custom_llm_provider="cohere_chat",
logging_obj=logging_obj,
)
collected: Final = await _collect(wrapper, sync_mode)
assert "".join(chunk.choices[0].delta.content or "" for chunk in collected if chunk.choices) == "Hi!"
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
@_SDK3_DROPPED_CONNECTIONS
async def test_sdk3_dropped_connection_before_finish_reason_raises_mid_stream_fallback(
logging_obj: Logging, sync_mode: bool, dropped_connection: Callable[[], Exception]
):
wrapper: Final = _openai_stream_over(
(_reset_test_chunk(content="Hel"),), dropped_connection(), sync_mode, logging_obj
)
with pytest.raises(MidStreamFallbackError) as excinfo:
await _collect(wrapper, sync_mode)
assert excinfo.value.is_pre_first_chunk is False
assert excinfo.value.generated_content == "Hel"
assert isinstance(excinfo.value.original_exception, litellm.APIConnectionError)
@pytest.mark.asyncio
@_SYNC_AND_ASYNC
@pytest.mark.parametrize(
"read_timeout", (_httpx2_read_timeout, _sdk_wrapped_read_timeout), ids=("httpx2", "sdk_wrapped_httpx2")
)
@pytest.mark.parametrize("finished", (False, True), ids=("before_finish_reason", "after_finish_reason"))
async def test_sdk3_read_timeout_mid_stream_falls_back_as_a_timeout(
logging_obj: Logging, sync_mode: bool, read_timeout: Callable[[], Exception], finished: bool
):
chunks: Final = (_reset_test_chunk(content="Hel"), _reset_test_chunk(finish_reason="stop"))
wrapper: Final = _openai_stream_over(chunks if finished else chunks[:1], read_timeout(), sync_mode, logging_obj)
with pytest.raises(MidStreamFallbackError) as excinfo:
await _collect(wrapper, sync_mode)
assert excinfo.value.is_pre_first_chunk is False
assert isinstance(excinfo.value.original_exception, litellm.Timeout)
assert excinfo.value.original_exception.status_code == 408
def test_openai_custom_tool_call_stream_deltas_survive_conversion(logging_obj: Logging):
"""
Regression test: OpenAI chat completions custom tool calls stream as
@ -6731,28 +7178,31 @@ _OPENAI_STREAM_JSON: Final = (
'"system_fingerprint":null,"choices":[{"finish_reason":null,"index":0,'
'"delta":{"provider_specific_fields":null,"refusal":null,"content":"","role":"assistant",'
'"function_call":null,"tool_calls":null,"audio":null},"logprobs":null}],'
'"provider_specific_fields":null,"citations":null,"service_tier":"default","obfuscation":"o0"}'
'"provider_specific_fields":null,"citations":null,"moderation":null,"obfuscation":"o0",'
'"service_tier":"default"}'
),
(
'{"id":"chatcmpl-1","created":1,"model":"gpt-4o","object":"chat.completion.chunk",'
'"system_fingerprint":null,"choices":[{"finish_reason":null,"index":0,'
'"delta":{"provider_specific_fields":null,"refusal":null,"content":"one","role":null,'
'"function_call":null,"tool_calls":null,"audio":null},"logprobs":null}],'
'"provider_specific_fields":null,"citations":null,"service_tier":"default","obfuscation":"o1"}'
'"provider_specific_fields":null,"citations":null,"moderation":null,"obfuscation":"o1",'
'"service_tier":"default"}'
),
(
'{"id":"chatcmpl-1","created":1,"model":"gpt-4o","object":"chat.completion.chunk",'
'"system_fingerprint":null,"choices":[{"finish_reason":null,"index":0,'
'"delta":{"provider_specific_fields":null,"refusal":null,"content":" two","role":null,'
'"function_call":null,"tool_calls":null,"audio":null},"logprobs":null}],'
'"provider_specific_fields":null,"citations":null,"service_tier":"default","obfuscation":"o2"}'
'"provider_specific_fields":null,"citations":null,"moderation":null,"obfuscation":"o2",'
'"service_tier":"default"}'
),
(
'{"id":"chatcmpl-1","created":1,"model":"gpt-4o","object":"chat.completion.chunk",'
'"system_fingerprint":null,"choices":[{"finish_reason":"stop","index":0,'
'"delta":{"provider_specific_fields":null,"content":null,"role":null,"function_call":null,'
'"tool_calls":null,"audio":null},"logprobs":null}],"provider_specific_fields":null,'
'"service_tier":"default","obfuscation":"o3"}'
'"moderation":null,"obfuscation":"o3","service_tier":"default"}'
),
(
'{"id":"chatcmpl-1","created":1,"model":"gpt-4o","object":"chat.completion.chunk",'
@ -6767,31 +7217,31 @@ _AZURE_STREAM_JSON: Final = (
'"system_fingerprint":null,"choices":[{"content_filter_results":{"hate":{"filtered":false,'
'"severity":"safe"}},"finish_reason":null,"index":0,"delta":{"provider_specific_fields":null,'
'"refusal":null,"content":"","role":"assistant","function_call":null,"tool_calls":null,"audio":null},'
'"logprobs":null}],"provider_specific_fields":null,"citations":null,"service_tier":"default",'
'"obfuscation":"o0"}'
'"logprobs":null}],"provider_specific_fields":null,"citations":null,"moderation":null,'
'"obfuscation":"o0","service_tier":"default"}'
),
(
'{"id":"chatcmpl-1","created":1,"model":"gpt-4o","object":"chat.completion.chunk",'
'"system_fingerprint":null,"choices":[{"content_filter_results":{"hate":{"filtered":false,'
'"severity":"safe"}},"finish_reason":null,"index":0,"delta":{"provider_specific_fields":null,'
'"refusal":null,"content":"one","role":null,"function_call":null,"tool_calls":null,"audio":null},'
'"logprobs":null}],"provider_specific_fields":null,"citations":null,"service_tier":"default",'
'"obfuscation":"o1"}'
'"logprobs":null}],"provider_specific_fields":null,"citations":null,"moderation":null,'
'"obfuscation":"o1","service_tier":"default"}'
),
(
'{"id":"chatcmpl-1","created":1,"model":"gpt-4o","object":"chat.completion.chunk",'
'"system_fingerprint":null,"choices":[{"content_filter_results":{"hate":{"filtered":false,'
'"severity":"safe"}},"finish_reason":null,"index":0,"delta":{"provider_specific_fields":null,'
'"refusal":null,"content":" two","role":null,"function_call":null,"tool_calls":null,"audio":null},'
'"logprobs":null}],"provider_specific_fields":null,"citations":null,"service_tier":"default",'
'"obfuscation":"o2"}'
'"logprobs":null}],"provider_specific_fields":null,"citations":null,"moderation":null,'
'"obfuscation":"o2","service_tier":"default"}'
),
(
'{"id":"chatcmpl-1","created":1,"model":"gpt-4o","object":"chat.completion.chunk",'
'"system_fingerprint":null,"choices":[{"finish_reason":"stop","index":0,'
'"delta":{"provider_specific_fields":null,"content":null,"role":null,"function_call":null,'
'"tool_calls":null,"audio":null},"logprobs":null}],"provider_specific_fields":null,'
'"service_tier":"default","obfuscation":"o3"}'
'"moderation":null,"obfuscation":"o3","service_tier":"default"}'
),
(
'{"id":"chatcmpl-1","created":1,"model":"gpt-4o","object":"chat.completion.chunk",'

View file

@ -271,6 +271,27 @@ def test_process_azure_endpoint_url(api_base, model, expected_endpoint):
assert result == expected_endpoint, "Unexpected endpoint"
@respx.mock
@pytest.mark.parametrize("api_version", ("2025-04-01-preview", "v1"))
def test_azure_image_generation_keeps_deployment_url_query_params(api_version: str):
route: Final = respx.post(url__regex=r"https://azure-gateway\.example\.com/.*").mock(
return_value=httpx.Response(200, json={"created": 1, "data": [{"b64_json": "aGk="}]})
)
litellm.image_generation(
model="azure/gpt-image-1",
prompt="a red circle",
api_base="https://azure-gateway.example.com/openai/deployments/gpt-image-1/images/generations?subscription-key=gateway-key",
api_version=api_version,
api_key="test-azure-key",
)
request_url: Final = route.calls.last.request.url
assert request_url.path.endswith("/images/generations")
assert request_url.params["subscription-key"] == "gateway-key"
assert request_url.params["api-version"] == api_version
@patch("azure.identity.UsernamePasswordCredential")
@patch("azure.identity.get_bearer_token_provider")
def test_get_azure_ad_token_from_username_password(mock_get_bearer_token_provider, mock_credential):

View file

@ -17,7 +17,9 @@ import httpx
import openai
import pytest
import requests
import respx
from azure.core.pipeline.transport import RequestsTransport
from openai import AsyncAzureOpenAI
import litellm
from litellm.llms.azure.common_utils import (
@ -33,11 +35,12 @@ from litellm.llms.azure.common_utils import (
from litellm.secret_managers.get_azure_ad_token_provider import (
get_azure_ad_token_provider,
)
from litellm.types.router import GenericLiteLLMParams
from litellm.types.router import GenericLiteLLMParams, LiteLLM_Params
from litellm.types.secret_managers.get_azure_ad_token_provider import (
AzureCredentialType,
)
from litellm.types.utils import CallTypes
from litellm.utils import get_model_region
# Mock the necessary dependencies
@ -2510,6 +2513,228 @@ def test_an_azure_client_litellm_built_its_own_http_client_for_is_still_closed(m
assert wrapper.is_closed() is True
def test_full_deployment_url_keeps_its_extra_query_params_on_the_sdk_request(monkeypatch: pytest.MonkeyPatch) -> None:
def echo_url(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"id": "chatcmpl-url",
"object": "chat.completion",
"created": 0,
"model": "gpt",
"choices": [
{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": str(request.url)}}
],
},
)
monkeypatch.setattr(litellm, "client_session", httpx.Client(transport=httpx.MockTransport(echo_url)))
client: Final = BaseAzureLLM().get_azure_openai_client(
api_key="azure-key",
api_base="https://gateway.example.com/openai/deployments/gpt/chat/completions"
"?api-version=2024-10-21&subscription-key=abc",
api_version="2025-02-01-preview",
litellm_params={},
_is_async=False,
)
response: Final = client.chat.completions.create(model="gpt", messages=[{"role": "user", "content": "hi"}])
sent: Final = httpx.URL(response.choices[0].message.content or "")
assert sent.path == "/openai/deployments/gpt/chat/completions"
assert dict(sent.params) == {"api-version": "2025-02-01-preview", "subscription-key": "abc"}
def _echo_chat_completion(content: str) -> httpx.Response:
return httpx.Response(
200,
json={
"id": "chatcmpl-echo",
"object": "chat.completion",
"created": 0,
"model": "gpt",
"choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": content}}],
},
)
@pytest.mark.parametrize(
"deployment_url,expected_path,expected_params",
[
pytest.param(
"https://extensions.example.com/openai/deployments/gpt/extensions/chat/completions?api-version=2024-02-01",
"/openai/deployments/gpt/extensions/chat/completions",
{"api-version": "2025-02-01-preview"},
id="on_your_data_extensions_route",
),
pytest.param(
"https://blank-query.example.com/openai/deployments/gpt/chat/completions"
"?api-version=2024-10-21&subscription-key=",
"/openai/deployments/gpt/chat/completions",
{"api-version": "2025-02-01-preview", "subscription-key": ""},
id="blank_query_value",
),
],
)
def test_full_deployment_url_reaches_the_same_operation_with_its_query(
monkeypatch: pytest.MonkeyPatch, deployment_url: str, expected_path: str, expected_params: dict[str, str]
) -> None:
monkeypatch.setattr(
litellm,
"client_session",
httpx.Client(transport=httpx.MockTransport(lambda request: _echo_chat_completion(str(request.url)))),
)
client: Final = BaseAzureLLM().get_azure_openai_client(
api_key="azure-key",
api_base=deployment_url,
api_version="2025-02-01-preview",
litellm_params={},
_is_async=False,
)
response: Final = client.chat.completions.create(model="gpt", messages=[{"role": "user", "content": "hi"}])
sent: Final = httpx.URL(response.choices[0].message.content or "")
assert sent.path == expected_path
assert dict(sent.params) == expected_params
def _auth_headers_of(request: httpx.Request) -> httpx.Response:
return _echo_chat_completion(
json.dumps({"api-key": request.headers.get("api-key"), "authorization": request.headers.get("authorization")})
)
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.parametrize(
"api_key,litellm_params,expected_headers",
[
pytest.param(
"azure-key", {}, {"api-key": "azure-key", "authorization": "Bearer azure-key"}, id="api_key_sends_both"
),
pytest.param(
"azure-key",
{"azure_ad_token": "entra-token"},
{"api-key": None, "authorization": "Bearer entra-token"},
id="ad_token_wins_over_api_key",
),
],
)
@pytest.mark.asyncio
async def test_azure_sdk_client_sends_the_auth_headers_sdk_2_sent(
monkeypatch: pytest.MonkeyPatch,
is_async: bool,
api_key: str,
litellm_params: dict[str, str],
expected_headers: dict[str, str | None],
) -> None:
transport: Final = httpx.MockTransport(_auth_headers_of)
monkeypatch.setattr(litellm, "client_session", httpx.Client(transport=transport))
monkeypatch.setattr(litellm, "aclient_session", httpx.AsyncClient(transport=transport))
client: Final = BaseAzureLLM().get_azure_openai_client(
api_key=api_key,
api_base=f"https://auth-{is_async}-{len(litellm_params)}.example.com",
api_version="2025-02-01-preview",
litellm_params=litellm_params,
_is_async=is_async,
)
messages: Final = [{"role": "user", "content": "hi"}]
response: Final = (
await client.chat.completions.create(model="gpt", messages=messages)
if isinstance(client, AsyncAzureOpenAI)
else client.chat.completions.create(model="gpt", messages=messages)
)
assert json.loads(response.choices[0].message.content or "{}") == expected_headers
def _region_only_for_key_and_bearer(request: httpx.Request) -> httpx.Response:
if (request.headers.get("api-key"), request.headers.get("authorization")) != ("azure-key", "Bearer azure-key"):
return httpx.Response(401, json={"error": {"message": "denied"}})
return httpx.Response(
200,
headers={"x-ms-region": "eastus"},
json={"id": "chatcmpl-region", "object": "chat.completion", "created": 0, "model": "gpt", "choices": []},
)
@pytest.mark.parametrize(
"api_base",
["https://region-lookup.openai.azure.com", "https://gateway.ai.cloudflare.com/v1/account/gateway/azure-openai/res"],
)
def test_azure_region_lookup_sends_the_key_and_bearer_headers(monkeypatch: pytest.MonkeyPatch, api_base: str) -> None:
session: Final = httpx.Client(transport=httpx.MockTransport(_region_only_for_key_and_bearer))
monkeypatch.setattr(litellm, "client_session", session)
params: Final = LiteLLM_Params(model="azure/gpt-region", api_key="azure-key", api_base=api_base)
assert get_model_region(litellm_params=params, mode="chat") == "eastus"
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.asyncio
async def test_azure_v1_deployment_keyed_only_by_the_openai_api_key_env_sends_that_key(
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, is_async: bool
) -> None:
for name in ("AZURE_API_KEY", "AZURE_OPENAI_API_KEY", "AZURE_AD_TOKEN", "AZURE_OPENAI_AD_TOKEN", "AZURE_CLIENT_ID"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "azure-key-in-openai-env")
monkeypatch.setenv("OPENAI_ADMIN_KEY", "sk-admin-key-for-org-apis")
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
api_base: Final = f"https://envkey-{is_async}.openai.azure.com"
route: Final = respx_mock.post(f"{api_base}/openai/v1/chat/completions").respond(
200,
json={
"id": "chatcmpl-envkey",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "hi"}}],
},
)
request: Final = {
"model": "azure/gpt-4o-mini",
"messages": [{"role": "user", "content": "hi"}],
"api_base": api_base,
"api_version": "v1",
}
response: Final = await litellm.acompletion(**request) if is_async else litellm.completion(**request)
assert response.choices[0].message.content == "hi"
assert route.calls.last.request.headers["authorization"] == "Bearer azure-key-in-openai-env"
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.asyncio
async def test_azure_v1_deployment_with_no_credential_is_an_authentication_error(
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, is_async: bool
) -> None:
for name in (
"AZURE_API_KEY",
"AZURE_OPENAI_API_KEY",
"AZURE_AD_TOKEN",
"AZURE_OPENAI_AD_TOKEN",
"AZURE_CLIENT_ID",
"OPENAI_API_KEY",
):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
api_base: Final = f"https://nokey-{is_async}.openai.azure.com"
route: Final = respx_mock.post(f"{api_base}/openai/v1/embeddings").respond(200, json={})
request: Final = {
"model": "azure/text-embedding-3-small",
"input": ["hi"],
"api_base": api_base,
"api_version": "v1",
}
with pytest.raises(litellm.AuthenticationError):
await litellm.aembedding(**request) if is_async else litellm.embedding(**request)
assert not route.called
_ENTRA_APP_CLIENT_ID: Final = "0d08e61f-6184-4980-9cef-a2e9128f4cb2"
_ENTRA_TRACE_ID: Final = "f0c149d3-ef20-4194-8b81-c81a5e002500"
_ENTRA_REJECTIONS: Final = {

View file

@ -164,6 +164,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

@ -1322,6 +1322,20 @@ async def test_refused_connection_maps_to_httpx_connect_error(
assert (cause.host, cause.port) == ("127.0.0.1", port)
async def _read_request_headers(reader: asyncio.StreamReader) -> bytes:
headers: Final = await reader.readuntil(b"\r\n\r\n")
content_length: Final = next(
(
int(header.split(b":", 1)[1])
for header in headers.split(b"\r\n")
if header.lower().startswith(b"content-length:")
),
0,
)
await reader.readexactly(content_length)
return headers
@pytest.mark.asyncio
async def test_reset_after_request_is_not_replayed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NO_PROXY", "127.0.0.1")
@ -1329,17 +1343,7 @@ async def test_reset_after_request_is_not_replayed(monkeypatch: pytest.MonkeyPat
received_requests: Final[list[bytes]] = [] # mutable-ok: local server captures requests
async def reset_after_request(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
headers: Final = await reader.readuntil(b"\r\n\r\n")
content_length: Final = next(
(
int(header.split(b":", 1)[1])
for header in headers.split(b"\r\n")
if header.lower().startswith(b"content-length:")
),
0,
)
await reader.readexactly(content_length)
received_requests.append(headers)
received_requests.append(await _read_request_headers(reader))
transport_socket: Final = writer.get_extra_info("socket")
assert transport_socket is not None
transport_socket.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
@ -1358,6 +1362,107 @@ async def test_reset_after_request_is_not_replayed(monkeypatch: pytest.MonkeyPat
await handler.close()
_SSE_EVENTS_BEFORE_DROP: Final = (
b'data: {"id":"chatcmpl-drop","object":"chat.completion.chunk","created":1,"model":"gpt-5.6",'
b'"choices":[{"index":0,"delta":{"role":"assistant","content":"1 2 "},"finish_reason":null}]}\n\n',
b'data: {"id":"chatcmpl-drop","object":"chat.completion.chunk","created":1,"model":"gpt-5.6",'
b'"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
)
async def _stream_events_then_drop_before_done(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
await _read_request_headers(reader)
chunked_events: Final = b"".join(b"%x\r\n%s\r\n" % (len(event), event) for event in _SSE_EVENTS_BEFORE_DROP)
writer.write(
b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n" + chunked_events
)
await writer.drain()
writer.close()
await writer.wait_closed()
async def _until_connection_drop_is_received(content: aiohttp.StreamReader) -> None:
while content.exception() is None:
await asyncio.sleep(0)
async def _read_until_error(stream: httpx.AsyncByteStream) -> tuple[bytes, Exception | None]:
chunks: Final[list[bytes]] = [] # mutable-ok: keeps the chunks read before the stream raises
try:
async for chunk in stream:
chunks.append(chunk)
except Exception as error:
return b"".join(chunks), error
return b"".join(chunks), None
@pytest.mark.asyncio
async def test_bytes_received_before_a_connection_drop_are_yielded_before_read_error() -> None:
server: Final = await asyncio.start_server(_stream_events_then_drop_before_done, "127.0.0.1", 0)
port: Final = server.sockets[0].getsockname()[1]
async with server, aiohttp.ClientSession() as session:
aiohttp_response: Final = await session.post(f"http://127.0.0.1:{port}/v1/chat/completions", data=b"{}")
await _until_connection_drop_is_received(aiohttp_response.content)
received, error = await _read_until_error(AiohttpResponseStream(aiohttp_response))
assert received == b"".join(_SSE_EVENTS_BEFORE_DROP)
assert isinstance(error, httpx.ReadError)
@pytest.mark.asyncio
async def test_finished_stream_dropped_before_done_ends_cleanly_through_acompletion(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("NO_PROXY", "127.0.0.1")
monkeypatch.setenv("no_proxy", "127.0.0.1")
upstream_responses: Final[list[aiohttp.ClientResponse]] = [] # mutable-ok: trace hook captures the response
async def capture_response(
_session: ClientSession, _context: object, params: aiohttp.TraceRequestEndParams
) -> None:
upstream_responses.append(params.response)
trace_config: Final = aiohttp.TraceConfig()
trace_config.on_request_end.append(capture_response)
server: Final = await asyncio.start_server(_stream_events_then_drop_before_done, "127.0.0.1", 0)
port: Final = server.sockets[0].getsockname()[1]
async with server, aiohttp.ClientSession(trace_configs=[trace_config]) as session:
stream: Final = await litellm.acompletion(
model="openai/gpt-5.6",
api_base=f"http://127.0.0.1:{port}/v1",
api_key="sk-local-test",
messages=[{"role": "user", "content": "count to two"}],
stream=True,
shared_session=session,
)
await _until_connection_drop_is_received(upstream_responses[0].content)
chunks: Final = [chunk async for chunk in stream]
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == "1 2 "
assert chunks[-1].choices[0].finish_reason == "stop"
class _ContentWithChangedBufferInternals(MockContent):
def _read_nowait(self, n: int) -> bytes:
raise TypeError(f"_read_nowait({n}) no longer drains the buffer")
@pytest.mark.asyncio
async def test_payload_error_still_maps_to_read_error_when_buffer_drain_breaks() -> None:
mock_response: Final = MockAiohttpResponse()
mock_response.content = _ContentWithChangedBufferInternals(
chunks=[b"chunk1", b"chunk2"],
exception_to_raise=aiohttp.ClientPayloadError("Response payload is not completed"),
exception_at_chunk=1,
)
received, error = await _read_until_error(AiohttpResponseStream(mock_response)) # pyright: ignore[reportArgumentType] # duck-typed aiohttp response double
assert received == b"chunk1"
assert isinstance(error, httpx.ReadError)
assert mock_response.closed is True
async def _new_session() -> aiohttp.ClientSession:
return aiohttp.ClientSession()

View file

@ -0,0 +1,86 @@
from typing import Final
import httpx
import openai
import pytest
from litellm.llms.custom_httpx.transport_errors import (
TransportErrorKind,
chained_transport_error_kind,
transport_error_classes,
transport_error_kind,
)
REQUEST: Final = httpx.Request("POST", "https://api.openai.com/v1/chat/completions")
def _raised_from(error: Exception, cause: Exception) -> Exception:
try:
raise error from cause
except Exception as raised:
return raised
def _raised_while_handling(error: Exception, handled: Exception) -> Exception:
try:
try:
raise handled
except Exception:
raise error
except Exception as raised:
return raised
@pytest.mark.parametrize("module_name", ("httpx", "httpx2"))
@pytest.mark.parametrize(
("class_name", "expected"),
(
("ReadTimeout", TransportErrorKind.TIMEOUT),
("ConnectTimeout", TransportErrorKind.TIMEOUT),
("ReadError", TransportErrorKind.DROPPED_CONNECTION),
("RemoteProtocolError", TransportErrorKind.DROPPED_CONNECTION),
("ConnectError", None),
("WriteError", None),
),
)
def test_transport_error_kind_matches_httpx_and_httpx2_alike(
module_name: str, class_name: str, expected: TransportErrorKind | None
):
module: Final = pytest.importorskip(module_name)
error: Final = getattr(module, class_name)("transport failure")
assert transport_error_kind(error) is expected
assert chained_transport_error_kind(error) is expected
assert all(isinstance(error, transport_error_classes(kind)) is (kind is expected) for kind in TransportErrorKind)
def test_transport_error_kind_ignores_values_that_are_not_transport_errors():
assert transport_error_kind(ValueError("decoder failure")) is None
assert transport_error_kind({"error": "not an exception"}) is None
def test_chained_kind_reads_the_transport_error_an_sdk_error_was_raised_from():
httpx2: Final = pytest.importorskip("httpx2")
dropped: Final = _raised_from(openai.APIConnectionError(request=REQUEST), httpx2.ReadError("peer closed"))
timed_out: Final = _raised_from(openai.APITimeoutError(request=REQUEST), httpx2.ReadTimeout("timed out"))
twice_wrapped: Final = _raised_from(ValueError("outer"), timed_out)
assert transport_error_kind(dropped) is None
assert chained_transport_error_kind(dropped) is TransportErrorKind.DROPPED_CONNECTION
assert chained_transport_error_kind(timed_out) is TransportErrorKind.TIMEOUT
assert chained_transport_error_kind(twice_wrapped) is TransportErrorKind.TIMEOUT
assert chained_transport_error_kind(openai.APIConnectionError(request=REQUEST)) is None
def test_chained_kind_ignores_an_error_raised_while_handling_a_transport_error():
contextual: Final = _raised_while_handling(ValueError("bug in a handler"), httpx.ReadError("connection reset"))
assert contextual.__context__ is not None
assert chained_transport_error_kind(contextual) is None
def test_chained_kind_stops_on_a_cause_cycle():
cyclic: Final = ValueError("caused by itself")
cyclic.__cause__ = cyclic
assert chained_transport_error_kind(cyclic) is None

View file

@ -5,12 +5,14 @@ import importlib
import inspect
import json
import os
from collections.abc import Awaitable, Callable, Mapping, Sequence
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence
from importlib.metadata import version
from types import ModuleType
from typing import Final, Literal, Protocol
from unittest.mock import MagicMock, Mock, patch
import httpx
import httpx2
import pytest
import respx
from openai import AsyncOpenAI, AzureOpenAI, OpenAI
@ -39,6 +41,7 @@ from litellm.llms.openai.openai import (
from litellm.types.utils import (
ImageResponse,
ModelResponse,
ModelResponseStream,
PromptTokensDetails,
)
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
@ -847,6 +850,58 @@ async def test_mapped_error_carries_the_upstream_error_response(
assert raised.value.response.headers["x-upstream-trace"] == "trace-7"
_SDK_TAKES_HTTPX2: Final = pytest.mark.skipif(
int(version("openai").split(".")[0]) < 3, reason="openai SDK 2 accepts only httpx clients"
)
def _sdk_default_transport_rejecting(_request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(
400,
content=_UPSTREAM_ERROR_BODY,
headers={"x-request-id": "req_upstream_400", "x-upstream-trace": "trace-7", "content-type": "application/json"},
)
async def _completion_on_the_sdks_default_transport() -> None:
client: Final = OpenAI(
api_key="transport-only",
base_url=_UPSTREAM_BASE,
max_retries=0,
http_client=httpx2.Client(transport=httpx2.MockTransport(_sdk_default_transport_rejecting)),
)
litellm.completion(messages=_MESSAGES, client=client, **_upstream("openai", "gpt-5.6"))
async def _acompletion_on_the_sdks_default_transport() -> None:
client: Final = AsyncOpenAI(
api_key="transport-only",
base_url=_UPSTREAM_BASE,
max_retries=0,
http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(_sdk_default_transport_rejecting)),
)
await litellm.acompletion(messages=_MESSAGES, client=client, **_upstream("openai", "gpt-5.6"))
@_SDK_TAKES_HTTPX2
@pytest.mark.asyncio
@pytest.mark.parametrize(
"route",
(_completion_on_the_sdks_default_transport, _acompletion_on_the_sdks_default_transport),
ids=("sync", "async"),
)
async def test_mapped_error_keeps_the_upstream_error_response_from_the_sdks_httpx2_transport(
route: Callable[[], Awaitable[None]],
) -> None:
with pytest.raises(litellm.BadRequestError) as raised:
await route()
assert raised.value.response.text == _UPSTREAM_ERROR_BODY
assert raised.value.response.headers["x-request-id"] == "req_upstream_400"
assert raised.value.response.headers["x-upstream-trace"] == "trace-7"
assert raised.value.response.request.url == httpx.URL(f"{_UPSTREAM_BASE}/chat/completions")
_UPSTREAM_SERVER_ERROR_BODY: Final = (
'{"error":{"message":"The server had an error while processing your request.","type":"server_error",'
'"param":null,"code":null}}'
@ -921,6 +976,29 @@ _PROVIDER_KEY: Final = "sk-provider-secret"
_QUERY_SECRET: Final = "query-secret"
def _httpx_client_answering(status_code: int, body: str) -> httpx.Client:
return httpx.Client(
transport=httpx.MockTransport(
lambda _request: httpx.Response(status_code, content=body, headers={"content-type": "application/json"})
)
)
def _httpx2_client_answering(status_code: int, body: str) -> httpx2.Client:
return httpx2.Client(
transport=httpx2.MockTransport(
lambda _request: httpx2.Response(status_code, content=body, headers={"content-type": "application/json"})
)
)
@pytest.mark.parametrize(
"http_client_answering",
(
pytest.param(_httpx_client_answering, id="httpx"),
pytest.param(_httpx2_client_answering, id="httpx2", marks=_SDK_TAKES_HTTPX2),
),
)
@pytest.mark.parametrize("provider", ("openai", "litellm_proxy"))
@pytest.mark.parametrize(
("status_code", "mapped_error", "body"),
@ -937,20 +1015,18 @@ _QUERY_SECRET: Final = "query-secret"
),
)
def test_mapped_error_keeps_the_upstream_body_without_the_provider_credentials(
provider: str, status_code: int, mapped_error: type[Exception], body: str
http_client_answering: Callable[[int, str], httpx.Client | httpx2.Client],
provider: str,
status_code: int,
mapped_error: type[Exception],
body: str,
) -> None:
client: Final = OpenAI(
api_key=_PROVIDER_KEY,
base_url=_UPSTREAM_BASE,
default_query={"key": _QUERY_SECRET},
max_retries=0,
http_client=httpx.Client(
transport=httpx.MockTransport(
lambda _request: httpx.Response(
status_code, content=body, headers={"content-type": "application/json"}
)
)
),
http_client=http_client_answering(status_code, body),
)
with pytest.raises(mapped_error) as raised:
@ -1092,6 +1168,84 @@ async def test_acompletion_streams_text_deltas_over_injected_transport(
assert last_finish == "stop"
def _sse_event(delta: Mapping[str, str], finish: str | None) -> bytes:
body: Final = {
"id": "chatcmpl-dropped",
"object": "chat.completion.chunk",
"created": 0,
"model": "gpt-5.6",
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
}
return f"data: {json.dumps(body)}\n\n".encode()
_FINISHED_EVENTS_WITHOUT_DONE: Final = (
_sse_event({"role": "assistant", "content": "Hel"}, None),
_sse_event({"content": "lo"}, "stop"),
)
def _finished_then_dropped_transport(http_backend: ModuleType) -> object:
class FinishedThenDropped(http_backend.SyncByteStream, http_backend.AsyncByteStream):
def __iter__(self) -> Iterator[bytes]:
yield from _FINISHED_EVENTS_WITHOUT_DONE
raise http_backend.ReadError("peer closed the connection before [DONE]")
async def __aiter__(self) -> AsyncIterator[bytes]:
for event in _FINISHED_EVENTS_WITHOUT_DONE:
yield event
raise http_backend.ReadError("peer closed the connection before [DONE]")
return http_backend.MockTransport(
lambda _request: http_backend.Response(
200, headers={"content-type": "text/event-stream"}, stream=FinishedThenDropped()
)
)
def _streamed_text_and_finish_reasons(chunks: list[ModelResponseStream]) -> tuple[str, list[str]]:
text: Final = "".join(part.choices[0].delta.content or "" for part in chunks if part.choices)
finish_reasons: Final = [part.choices[0].finish_reason for part in chunks if part.choices and part.choices[0].finish_reason]
return text, finish_reasons
@pytest.mark.asyncio
async def test_acompletion_ends_a_finished_stream_cleanly_when_the_connection_drops_before_done(
http_backend: ModuleType,
):
async with http_backend.AsyncClient(transport=_finished_then_dropped_transport(http_backend)) as http_client:
stream: Final = await litellm.acompletion(
model="openai/gpt-5.6",
client=AsyncOpenAI(api_key="transport-only", http_client=http_client),
messages=[{"role": "user", "content": "dropped-stream-request"}],
stream=True,
num_retries=0,
max_retries=0,
)
async def drain() -> list[ModelResponseStream]:
return [part async for part in stream]
chunks: Final = await asyncio.wait_for(drain(), timeout=10)
assert _streamed_text_and_finish_reasons(chunks) == ("Hello", ["stop"])
def test_completion_ends_a_finished_stream_cleanly_when_the_connection_drops_before_done(http_backend: ModuleType):
with http_backend.Client(transport=_finished_then_dropped_transport(http_backend)) as http_client:
stream: Final = litellm.completion(
model="openai/gpt-5.6",
client=OpenAI(api_key="transport-only", http_client=http_client),
messages=[{"role": "user", "content": "dropped-stream-request"}],
stream=True,
num_retries=0,
max_retries=0,
)
chunks: Final = list(stream)
assert _streamed_text_and_finish_reasons(chunks) == ("Hello", ["stop"])
@pytest.mark.asyncio
async def test_acompletion_streams_tool_call_arguments_over_injected_transport(http_backend: ModuleType):
outbound: Final = asyncio.Queue()

View file

@ -85,10 +85,11 @@ API_FUNCTION_PARAMS = [
@pytest.mark.parametrize("function_name,is_async,args", API_FUNCTION_PARAMS)
@pytest.mark.asyncio
async def test_openai_client_reuse(function_name, is_async, args):
async def test_openai_client_reuse(function_name, is_async, args, monkeypatch: pytest.MonkeyPatch):
"""
Test that multiple API calls reuse the same OpenAI client
"""
monkeypatch.setenv("OPENAI_API_KEY", "sk-client-reuse")
# Determine which client class to mock based on whether the test is async
client_path = "litellm.llms.openai.openai.AsyncOpenAI" if is_async else "litellm.llms.openai.openai.OpenAI"
@ -694,6 +695,35 @@ async def test_azure_gateway_and_image_variations_use_the_callers_async_session(
assert not session.is_closed
@pytest.mark.parametrize("is_async", [False, True])
@pytest.mark.asyncio
async def test_unkeyed_openai_deployment_never_sends_the_openai_admin_key(
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, is_async: bool
) -> None:
monkeypatch.setenv("OPENAI_ADMIN_KEY", "sk-admin-key-for-org-apis")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
api_base: Final = f"https://unkeyed-{is_async}.example.com/v1"
route: Final = respx_mock.post(f"{api_base}/chat/completions").respond(
200,
json={
"id": "chatcmpl-unkeyed",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "hi"}}],
},
)
request: Final = {
"model": "openai/gpt-4o-mini",
"messages": [{"role": "user", "content": "hi"}],
"api_base": api_base,
}
with pytest.raises(litellm.AuthenticationError, match=r"Missing credentials|The api_key client option must be set"):
await litellm.acompletion(**request) if is_async else litellm.completion(**request)
assert not route.called
def test_reasoning_drop_warning_caps_a_large_caller_value(caplog: pytest.LogCaptureFixture) -> None:
error: Final = httpx.HTTPStatusError(

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

@ -4215,7 +4215,7 @@ def test_azure_passthrough_keeps_its_own_openai_beta_rule(
@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

@ -1037,7 +1037,7 @@ def test_streamed_transcription_error_mid_stream_ends_with_an_error_event_and_ru
assert tuple(frame.get("delta") for frame in frames[:2]) == ("hello ", "world")
assert len(frames) == 3
assert data_lines[-1] == "[DONE]"
assert "upstream connection dropped" in frames[2]["error"]["message"]
assert "APIConnectionError" in frames[2]["error"]["message"]
assert "/srv/app/stt_stream.py" not in frames[2]["error"]["message"]
assert "stt.internal.corp" not in frames[2]["error"]["message"]
assert set(frames[2]["error"]) == {"message", "type", "param", "code"}

View file

@ -212,7 +212,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

@ -1753,7 +1753,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

@ -3440,9 +3440,9 @@ class TestUsageTransformation:
assert response_usage.input_tokens_details is not None
assert response_usage.input_tokens_details.cached_tokens == 5
assert response_usage.input_tokens_details.text_tokens == 8
assert {"cache_write_tokens", "cache_write_tokens_details"}.isdisjoint(
response_usage.input_tokens_details.model_dump()
)
dumped_details: Final = response_usage.input_tokens_details.model_dump()
assert dumped_details["cache_write_tokens"] == 0
assert "cache_write_tokens_details" not in dumped_details
def test_transform_usage_with_cached_tokens_gemini(self):
"""Test that cached_tokens from Gemini are properly transformed to input_tokens_details"""

View file

@ -415,6 +415,20 @@ class TestResponseAPILoggingUtils:
assert result.prompt_tokens_details.cache_creation_tokens == 10059
assert result.prompt_tokens_details.cached_tokens == 0
def test_transform_response_api_usage_keeps_cache_creation_tokens_without_cache_writes(self) -> None:
unvalidated_usage: Final = ResponseAPIUsage.model_construct(
input_tokens=100,
output_tokens=1,
total_tokens=101,
input_tokens_details={"cached_tokens": 0, "cache_creation_tokens": 50},
)
usage: Final = ResponseAPILoggingUtils.transform_response_api_usage_to_chat_usage(unvalidated_usage)
assert usage.prompt_tokens_details is not None
assert usage.prompt_tokens_details.cache_creation_tokens == 50
assert usage.prompt_tokens_details.cache_write_tokens == 50
@pytest.mark.parametrize("usage_shape", ["dict", "validated_model", "unvalidated_model"])
def test_transform_response_api_usage_prices_12h_cache_writes(self, usage_shape: str) -> None:
usage_data: Final = {

View file

@ -768,6 +768,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", 500),
("bio_policy", 500),
("misalignment_policy_violation", 500),
("totally_unknown_future_code", 500),
("429", 429),
("503", 503),

View file

@ -11587,6 +11587,42 @@ async def test_bedrock_strict_tools_limit_rejection_is_not_sent_again_when_no_to
assert len(sent) == 1
_AZURE_ENV_KEY_RESPONSES: Final = {
"embedding": {
"object": "list",
"data": [{"object": "embedding", "index": 0, "embedding": [0.1]}],
"model": "text-embedding-3-small",
"usage": {"prompt_tokens": 1, "total_tokens": 1},
},
"transcription": {"text": "Four score and seven years ago"},
}
@pytest.mark.parametrize("call", ["embedding", "transcription"])
def test_azure_key_from_azure_openai_api_key_env_sends_the_bearer_header(
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, call: str
) -> None:
monkeypatch.delenv("AZURE_API_KEY", raising=False)
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-env-key")
api_base: Final = f"https://env-key-{call}.openai.azure.com"
route: Final = respx_mock.post(url__regex=rf"{api_base}/openai/deployments/.*").respond(
200, json=_AZURE_ENV_KEY_RESPONSES[call]
)
target: Final = {"api_base": api_base, "api_version": "2024-06-01", "max_retries": 0}
if call == "embedding":
litellm.embedding(model="azure/text-embedding-3-small", input=["hello"], **target)
else:
litellm.transcription(
model="azure/whisper-1",
file=("gettysburg.wav", (Path(__file__).parents[1] / "gettysburg.wav").read_bytes(), "audio/wav"),
**target,
)
sent: Final = route.calls.last.request.headers
assert (sent.get("api-key"), sent.get("authorization")) == ("azure-env-key", "Bearer azure-env-key")
def test_supplied_openai_client_base_url_decides_data_residency(monkeypatch: pytest.MonkeyPatch) -> None:
"""A caller-supplied OpenAI client sends the request to its own base_url, so billing must use that
host and not an env-configured FedRAMP or regional base."""

View file

@ -98,11 +98,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

View file

@ -8,15 +8,20 @@ import pytest
import json
from openai.types.responses.response_usage import ResponseUsage as OpenAIResponseUsage
import litellm
from litellm.types.llms.openai import (
ContentPartDoneEvent,
ContentPartDonePartOutputText,
HttpxBinaryResponseContent,
InputTokensDetails,
OpenAIChatCompletionLogprobsContent,
OpenAIModerationResponse,
OpenAIVideoObject,
OutputTextDeltaEvent,
OutputTokensDetails,
ResponseAPIUsage,
ResponseCompletedEvent,
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
@ -574,6 +579,35 @@ def test_shell_call_output_the_sdk_cannot_type_serializes_as_sent():
assert json.loads(response.model_dump_json())["output"] == [item]
@pytest.mark.parametrize(
"dump",
[
lambda response: response.model_dump(exclude_none=True),
lambda response: json.loads(response.model_dump_json(exclude_none=True)),
],
ids=["python", "json"],
)
def test_function_call_and_tool_dump_async_under_its_wire_name(dump) -> None:
call: Final = {
"id": "fc_1",
"type": "function_call",
"call_id": "call_1",
"name": "get_weather",
"arguments": "{}",
"status": "completed",
"async": True,
}
tool: Final = {"type": "function", "name": "get_weather", "parameters": {}, "strict": True, "async": False}
response: Final = ResponsesAPIResponse.model_validate(
{"id": "resp_1", "created_at": 1, "object": "response", "status": "completed", "output": [call], "tools": [tool]}
)
dumped: Final = dump(response)
assert (dumped["output"], dumped["tools"]) == ([call], [tool])
assert ResponsesAPIResponse.model_validate(dumped).model_dump(exclude_none=True)["output"] == [call]
def test_normalize_fine_tuning_job_dict_maps_azure_pending():
from litellm.llms.openai.fine_tuning.handler import _normalize_fine_tuning_job_dict
@ -820,6 +854,22 @@ def test_rebuilding_a_dumped_responses_answer_keeps_the_tool_search_call_keys(ex
assert rebuilt.model_dump()["output"] == [search_call]
@pytest.mark.parametrize(("cache_write_tokens", "reported"), ((None, 0), (7, 7)))
def test_responses_usage_dump_matches_the_openai_sdk_usage_schema(cache_write_tokens: int | None, reported: int) -> None:
usage: Final = ResponseAPIUsage(
input_tokens=10,
output_tokens=2,
total_tokens=12,
input_tokens_details=InputTokensDetails(cached_tokens=5, cache_write_tokens=cache_write_tokens),
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
)
sdk_usage: Final = OpenAIResponseUsage.model_validate(usage.model_dump(exclude_none=True))
assert sdk_usage.input_tokens_details.cache_write_tokens == reported
assert sdk_usage.input_tokens_details.cached_tokens == 5
def _text_delta() -> OutputTextDeltaEvent:
return OutputTextDeltaEvent(
type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, item_id="msg_1", output_index=0, content_index=0, delta="hi"

File diff suppressed because it is too large Load diff

675
uv.lock generated

File diff suppressed because it is too large Load diff