mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge d3c0ecd063 into 94861ecdf7
This commit is contained in:
commit
95a7bf74ed
70 changed files with 4359 additions and 680 deletions
|
|
@ -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[@]}"
|
||||
|
||||
|
|
|
|||
2
.github/workflows/test-unit.yml
vendored
2
.github/workflows/test-unit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
59
litellm/llms/custom_httpx/transport_errors.py
Normal file
59
litellm/llms/custom_httpx/transport_errors.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -72,7 +72,7 @@ def _failure_details(original: Exception) -> _FailureDetails:
|
|||
upstream: Final = _FailureDetails.model_validate(body)
|
||||
return _FailureDetails(
|
||||
message=upstream.message or mapped.message,
|
||||
code=upstream.code if upstream.code is not None else mapped.code,
|
||||
code=upstream.code if "code" in body and body["code"] is not None else mapped.code,
|
||||
type=upstream.type or mapped.type,
|
||||
status_code=mapped.status_code,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from types import MappingProxyType
|
|||
from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol, 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
56
tests/integration/compatibility/_retained_openai_consumer.py
Normal file
56
tests/integration/compatibility/_retained_openai_consumer.py
Normal 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:])
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",'
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
86
tests/unit/llms/custom_httpx/test_transport_errors.py
Normal file
86
tests/unit/llms/custom_httpx/test_transport_errors.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -56,22 +56,7 @@ CHAT_COMPLETION_BODY: Final = {
|
|||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_sdk_token_exchange_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
native_client_factory: Final = getattr(sys.modules.get("openai.auth._workload"), "DefaultHttpx2Client", None)
|
||||
if native_client_factory is not None:
|
||||
import httpx2
|
||||
|
||||
def handle_exchange(request: httpx2.Request) -> httpx2.Response:
|
||||
response: Final = respx.mock.handler(
|
||||
httpx.Request(request.method, str(request.url), headers=dict(request.headers), content=request.content)
|
||||
)
|
||||
return httpx2.Response(response.status_code, headers=dict(response.headers), content=response.content)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"openai.auth._workload.DefaultHttpx2Client",
|
||||
partial(native_client_factory, transport=httpx2.MockTransport(handle_exchange), trust_env=False),
|
||||
)
|
||||
pytestmark = pytest.mark.usefixtures("openai_token_exchange_via_httpx")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1119,6 +1119,7 @@ class _ModerationsEndpointThatRecordsTheAuthorization:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("openai_token_exchange_via_httpx")
|
||||
@respx.mock
|
||||
async def test_openai_moderation_sends_the_federated_bearer_when_only_workload_identity_is_set(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
|
|
@ -1149,6 +1150,7 @@ async def test_openai_moderation_sends_the_federated_bearer_when_only_workload_i
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("openai_token_exchange_via_httpx")
|
||||
@respx.mock
|
||||
async def test_openai_moderation_federates_with_the_identity_in_its_own_guardrail_config(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
928
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
928
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue