mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-04 07:27:51 +00:00
* fix(files): default a missing file object type to "file" so OpenAI-compatible servers that omit it stop 500ing * fix(batches): accept the vLLM production-stack router's batch object The router leaves out the batch object's "object" field and reports "pending" and "running" statuses, so batch create and retrieve through a hosted_vllm deployment failed validation with a 500. A missing or null object now reads as "batch", pending maps to validating and running to in_progress, and any other unknown value is still rejected * fix(batches): return OpenAI batch objects from batch list behind the vLLM router * fix(batches): type the batch list conversion without a TypeVar and refuse an async client on the sync path * fix(batches): keep batch list items as OpenAI's Batch so an odd item never fails a list * refactor(types): reuse the shared JSON value alias on the batch type * fix: return the provider page for an async client in sync batch list and pass through non-string list statuses * test: inject the SDK's own transport in the sync batch list test so it passes on OpenAI SDK 3.x --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2363 lines
82 KiB
Python
2363 lines
82 KiB
Python
import asyncio
|
|
import gzip
|
|
import importlib
|
|
import json
|
|
import os
|
|
from collections.abc import Awaitable, Callable, Mapping
|
|
from types import ModuleType
|
|
from typing import Final, Literal, Protocol
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
from openai import AsyncOpenAI, AzureOpenAI, OpenAI
|
|
from openai._legacy_response import LegacyAPIResponse
|
|
from openai._types import Response as SDKResponse
|
|
from pydantic import BaseModel, ConfigDict, JsonValue
|
|
|
|
import litellm
|
|
from litellm import create_thread, get_thread
|
|
from litellm.caching.caching import Cache, LiteLLMCacheType
|
|
from litellm.llms.openai.openai import (
|
|
AssistantEventHandler,
|
|
AsyncAssistantEventHandler,
|
|
AsyncCursorPage,
|
|
MessageData,
|
|
OpenAIBatchesAPI,
|
|
OpenAIChatCompletion,
|
|
OpenAIConfig,
|
|
OpenAIMessage as Message,
|
|
Run,
|
|
SyncCursorPage,
|
|
Thread,
|
|
)
|
|
from litellm.types.utils import (
|
|
ImageResponse,
|
|
ModelResponse,
|
|
PromptTokensDetails,
|
|
)
|
|
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
|
from litellm.utils import _invalidate_model_cost_lowercase_map
|
|
from openai.types.beta.assistant import Assistant
|
|
from openai.types.beta.assistant_deleted import AssistantDeleted
|
|
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
|
|
from litellm.proxy.utils import model_dump_with_preserved_fields
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"api_base",
|
|
[
|
|
None,
|
|
"https://api.openai.com/v1",
|
|
"https://api.openai.com:443/v1",
|
|
"https://southcentralus.privatelink.api.openai.com/v1",
|
|
"https://eu.api.openai.com/v1",
|
|
"https://us.api.openai.com/v1",
|
|
"HTTPS://API.OPENAI.COM/v1/",
|
|
],
|
|
)
|
|
def test_get_stream_options_defaults_include_usage_on_every_openai_backed_host(api_base):
|
|
"""
|
|
PrivateLink and regional hostnames reach the real OpenAI backend, so a stream with no caller
|
|
stream_options must ask for the usage chunk exactly as the default base does. Regression guard
|
|
for LIT-6875: spend for those deployments fell back to local token counting.
|
|
"""
|
|
assert OpenAIChatCompletion().get_stream_options(stream_options=None, api_base=api_base) == {
|
|
"stream_options": {"include_usage": True}
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"api_base",
|
|
[
|
|
"https://my-gateway.example/v1",
|
|
"https://api.openai.com.evil.example/v1",
|
|
"https://notapi.openai.com/v1",
|
|
"https://gateway.example/v1?upstream=api.openai.com",
|
|
"https://openai.internal.example/api.openai.com/v1",
|
|
],
|
|
)
|
|
def test_get_stream_options_leaves_foreign_hosts_without_a_usage_default(api_base):
|
|
"""Only the host decides: an OpenAI-compatible backend elsewhere may not support stream_options at all."""
|
|
assert OpenAIChatCompletion().get_stream_options(stream_options=None, api_base=api_base) == {}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"api_base",
|
|
["https://southcentralus.privatelink.api.openai.com/v1", "https://my-gateway.example/v1"],
|
|
)
|
|
def test_get_stream_options_passes_caller_stream_options_through_on_any_host(api_base):
|
|
caller_options = {"include_usage": False}
|
|
assert OpenAIChatCompletion().get_stream_options(stream_options=caller_options, api_base=api_base) == {
|
|
"stream_options": caller_options
|
|
}
|
|
|
|
|
|
@pytest.fixture(params=("httpx", "sdk"))
|
|
def http_backend(request: pytest.FixtureRequest) -> ModuleType:
|
|
return importlib.import_module("httpx" if request.param == "httpx" else SDKResponse.__module__)
|
|
|
|
|
|
@pytest.fixture(params=("openai", "perplexity", "cerebras", "nvidia_nim"))
|
|
def sdk_provider(request: pytest.FixtureRequest) -> str:
|
|
return str(request.param)
|
|
|
|
|
|
class _Request(Protocol):
|
|
@property
|
|
def content(self) -> bytes: ...
|
|
|
|
@property
|
|
def extensions(self) -> Mapping[str, object]: ...
|
|
|
|
@property
|
|
def url(self) -> object: ...
|
|
|
|
@property
|
|
def headers(self) -> Mapping[str, str]: ...
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acompletion_returns_json_reply_over_injected_transport(
|
|
http_backend: ModuleType, sdk_provider: str, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
outbound: Final = asyncio.Queue()
|
|
|
|
def respond(request: _Request) -> httpx.Response | SDKResponse:
|
|
assert str(request.url) == f"https://{sdk_provider}.example/v1/chat/completions"
|
|
assert request.headers["authorization"] == f"Bearer {sdk_provider}-key"
|
|
assert request.extensions["timeout"] == {"connect": 7, "read": 7, "write": 7, "pool": 7}
|
|
outbound.put_nowait(json.loads(request.content))
|
|
return http_backend.Response(
|
|
200,
|
|
json={
|
|
"id": "chatcmpl-smoke",
|
|
"object": "chat.completion",
|
|
"created": 0,
|
|
"model": "sdk-compat",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "smoke-json-reply"},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
|
},
|
|
)
|
|
|
|
async with http_backend.AsyncClient(transport=http_backend.MockTransport(respond)) as http_client:
|
|
monkeypatch.setattr(litellm, "aclient_session", http_client)
|
|
response: Final = await asyncio.wait_for(
|
|
litellm.acompletion(
|
|
model=f"{sdk_provider}/sdk-compat",
|
|
api_key=f"{sdk_provider}-key",
|
|
api_base=f"https://{sdk_provider}.example/v1",
|
|
messages=[{"role": "user", "content": "smoke-json-request"}],
|
|
timeout=7,
|
|
num_retries=0,
|
|
max_retries=0,
|
|
),
|
|
timeout=10,
|
|
)
|
|
request: Final = await asyncio.wait_for(outbound.get(), timeout=10)
|
|
assert request["model"] == "sdk-compat"
|
|
assert request["messages"] == [{"role": "user", "content": "smoke-json-request"}]
|
|
assert not request.get("stream")
|
|
assert outbound.empty()
|
|
assert response.choices[0].message.content == "smoke-json-reply"
|
|
assert response.choices[0].finish_reason == "stop"
|
|
assert response.usage.total_tokens == 15
|
|
assert not http_client.is_closed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acompletion_returns_prompt_cache_details_over_injected_transport():
|
|
outbound: Final = asyncio.Queue()
|
|
|
|
def respond(request: httpx.Request) -> httpx.Response:
|
|
outbound.put_nowait(json.loads(request.content))
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "chatcmpl-cache",
|
|
"object": "chat.completion",
|
|
"created": 0,
|
|
"model": "gpt-5.6",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "cached reply"},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {
|
|
"prompt_tokens": 12,
|
|
"completion_tokens": 4,
|
|
"total_tokens": 16,
|
|
"prompt_tokens_details": {"cached_tokens": 7},
|
|
},
|
|
},
|
|
)
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client:
|
|
client: Final = AsyncOpenAI(api_key="transport-only", http_client=http_client)
|
|
response: Final = await asyncio.wait_for(
|
|
litellm.acompletion(
|
|
model="openai/gpt-5.6",
|
|
api_key="transport-only",
|
|
client=client,
|
|
messages=[{"role": "user", "content": "cached prompt"}],
|
|
num_retries=0,
|
|
max_retries=0,
|
|
),
|
|
timeout=10,
|
|
)
|
|
|
|
request_body: Final = await asyncio.wait_for(outbound.get(), timeout=10)
|
|
assert request_body == {
|
|
"messages": [{"role": "user", "content": "cached prompt"}],
|
|
"model": "gpt-5.6",
|
|
}
|
|
prompt_tokens_details: Final = response.usage.prompt_tokens_details
|
|
assert isinstance(prompt_tokens_details, PromptTokensDetails)
|
|
assert prompt_tokens_details.cached_tokens == 7
|
|
|
|
|
|
_REASONING_TOOL_CALL_REPLY: Final = {
|
|
"id": "chatcmpl-fake-stream",
|
|
"object": "chat.completion",
|
|
"created": 0,
|
|
"model": "o4-mini",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"finish_reason": "tool_calls",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_weather",
|
|
"type": "function",
|
|
"function": {"name": "get_weather", "arguments": '{"city": "Paris"}'},
|
|
},
|
|
{
|
|
"id": "call_time",
|
|
"type": "function",
|
|
"function": {"name": "get_time", "arguments": '{"city": "Paris"}'},
|
|
},
|
|
],
|
|
},
|
|
}
|
|
],
|
|
"usage": {
|
|
"prompt_tokens": 85,
|
|
"completion_tokens": 1955,
|
|
"total_tokens": 2040,
|
|
"prompt_tokens_details": {"cached_tokens": 64},
|
|
"completion_tokens_details": {"reasoning_tokens": 1920},
|
|
},
|
|
}
|
|
|
|
|
|
def test_azure_o_series_fake_stream_keeps_every_tool_call_and_token_detail():
|
|
request_bodies: Final[list[dict[str, object]]] = []
|
|
|
|
def respond(request: httpx.Request) -> httpx.Response:
|
|
request_bodies.append(json.loads(request.content))
|
|
return httpx.Response(200, json=_REASONING_TOOL_CALL_REPLY)
|
|
|
|
client: Final = AzureOpenAI(
|
|
api_key="transport-only",
|
|
api_version="2025-03-01-preview",
|
|
azure_endpoint="https://fake-stream.openai.azure.com",
|
|
http_client=httpx.Client(transport=httpx.MockTransport(respond)),
|
|
)
|
|
chunks: Final = list(
|
|
litellm.completion(
|
|
model="azure/o_series/reasoning-deployment",
|
|
api_key="transport-only",
|
|
api_version="2025-03-01-preview",
|
|
api_base="https://fake-stream.openai.azure.com",
|
|
client=client,
|
|
messages=[{"role": "user", "content": "weather and time in Paris?"}],
|
|
stream=True,
|
|
stream_options={"include_usage": True},
|
|
num_retries=0,
|
|
)
|
|
)
|
|
rebuilt: Final = litellm.stream_chunk_builder(chunks)
|
|
|
|
assert [body.get("stream") for body in request_bodies] == [None]
|
|
assert rebuilt is not None
|
|
assert rebuilt.choices[0].finish_reason == "tool_calls"
|
|
assert [call.function.name for call in rebuilt.choices[0].message.tool_calls] == ["get_weather", "get_time"]
|
|
assert rebuilt.usage.prompt_tokens_details.cached_tokens == 64
|
|
assert rebuilt.usage.completion_tokens_details.reasoning_tokens == 1920
|
|
|
|
|
|
@respx.mock
|
|
def test_completion_defaults_missing_message_role_and_preserves_omitted_fields() -> None:
|
|
route: Final = respx.post("https://api.openai.com/v1/chat/completions").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "chatcmpl-missing-role",
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "gpt-5.4",
|
|
"choices": [{"index": 0, "message": {"content": "hi"}, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
},
|
|
)
|
|
)
|
|
|
|
response: Final = litellm.completion(
|
|
model="openai/gpt-5.4",
|
|
api_base="https://api.openai.com/v1",
|
|
api_key="sk-test",
|
|
messages=[{"role": "user", "content": "Hi"}],
|
|
num_retries=0,
|
|
)
|
|
|
|
assert route.called
|
|
assert response.choices[0].message.role == "assistant"
|
|
assert model_dump_with_preserved_fields(response, exclude_unset=True) == {
|
|
"id": "chatcmpl-missing-role",
|
|
"choices": [
|
|
{
|
|
"finish_reason": "stop",
|
|
"index": 0,
|
|
"message": {"content": "hi", "role": "assistant"},
|
|
"provider_specific_fields": {},
|
|
}
|
|
],
|
|
"created": 1,
|
|
"model": "gpt-5.4",
|
|
"object": "chat.completion",
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("status", "expected_error"),
|
|
(
|
|
(400, litellm.BadRequestError),
|
|
(429, litellm.RateLimitError),
|
|
(503, litellm.ServiceUnavailableError),
|
|
(None, litellm.Timeout),
|
|
),
|
|
)
|
|
async def test_acompletion_preserves_public_errors_for_both_http_clients(
|
|
http_backend: ModuleType,
|
|
sdk_provider: str,
|
|
status: int | None,
|
|
expected_error: type[Exception],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
def respond(request: _Request) -> httpx.Response | SDKResponse:
|
|
if status is None:
|
|
raise http_backend.ReadTimeout("upstream timeout", request=request)
|
|
return http_backend.Response(status, json={"error": {"message": "upstream failure", "type": "api_error"}})
|
|
|
|
async with http_backend.AsyncClient(transport=http_backend.MockTransport(respond)) as http_client:
|
|
monkeypatch.setattr(litellm, "aclient_session", http_client)
|
|
with pytest.raises(expected_error, match="timed out" if status is None else "upstream failure"):
|
|
await litellm.acompletion(
|
|
model=f"{sdk_provider}/sdk-compat",
|
|
api_key=f"{sdk_provider}-key",
|
|
api_base=f"https://{sdk_provider}.example/v1",
|
|
messages=[{"role": "user", "content": "request"}],
|
|
num_retries=0,
|
|
max_retries=0,
|
|
)
|
|
assert not http_client.is_closed
|
|
|
|
|
|
_UPSTREAM_ERROR_BODY: Final = (
|
|
'{"error":{"message":"upstream rejected the request","type":"invalid_request_error",'
|
|
'"param":"response_format.type","code":"invalid_value"}}'
|
|
)
|
|
_UPSTREAM_BASE: Final = "https://upstream.example/v1"
|
|
_UPSTREAM_TRANSPORT: Final = httpx.MockTransport(
|
|
lambda _request: httpx.Response(
|
|
400,
|
|
content=_UPSTREAM_ERROR_BODY,
|
|
headers={"x-request-id": "req_upstream_400", "x-upstream-trace": "trace-7", "content-type": "application/json"},
|
|
)
|
|
)
|
|
_MESSAGES: Final = [{"role": "user", "content": "hi"}]
|
|
|
|
|
|
def _rejecting_client() -> OpenAI:
|
|
return OpenAI(
|
|
api_key="transport-only",
|
|
base_url=_UPSTREAM_BASE,
|
|
max_retries=0,
|
|
http_client=httpx.Client(transport=_UPSTREAM_TRANSPORT),
|
|
)
|
|
|
|
|
|
def _rejecting_async_client() -> AsyncOpenAI:
|
|
return AsyncOpenAI(
|
|
api_key="transport-only",
|
|
base_url=_UPSTREAM_BASE,
|
|
max_retries=0,
|
|
http_client=httpx.AsyncClient(transport=_UPSTREAM_TRANSPORT),
|
|
)
|
|
|
|
|
|
def _upstream(provider: str, model: str) -> dict[str, object]:
|
|
return {"model": f"{provider}/{model}", "api_base": _UPSTREAM_BASE, "api_key": "transport-only", "num_retries": 0}
|
|
|
|
|
|
async def _completion(provider: str) -> None:
|
|
litellm.completion(messages=_MESSAGES, client=_rejecting_client(), **_upstream(provider, "gpt-5.6"))
|
|
|
|
|
|
async def _completion_stream(provider: str) -> None:
|
|
stream: Final = litellm.completion(
|
|
messages=_MESSAGES, client=_rejecting_client(), stream=True, **_upstream(provider, "gpt-5.6")
|
|
)
|
|
list(stream)
|
|
|
|
|
|
async def _acompletion(provider: str) -> None:
|
|
await litellm.acompletion(messages=_MESSAGES, client=_rejecting_async_client(), **_upstream(provider, "gpt-5.6"))
|
|
|
|
|
|
async def _acompletion_stream(provider: str) -> None:
|
|
stream: Final = await litellm.acompletion(
|
|
messages=_MESSAGES, client=_rejecting_async_client(), stream=True, **_upstream(provider, "gpt-5.6")
|
|
)
|
|
async for _ in stream:
|
|
pass
|
|
|
|
|
|
async def _embedding(provider: str) -> None:
|
|
litellm.embedding(input=["hi"], client=_rejecting_client(), **_upstream(provider, "text-embedding-3-large"))
|
|
|
|
|
|
async def _aembedding(provider: str) -> None:
|
|
await litellm.aembedding(
|
|
input=["hi"], client=_rejecting_async_client(), **_upstream(provider, "text-embedding-3-large")
|
|
)
|
|
|
|
|
|
async def _image_generation(provider: str) -> None:
|
|
litellm.image_generation(prompt="a cat", client=_rejecting_client(), **_upstream(provider, "gpt-image-2"))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("provider", ("openai", "litellm_proxy"))
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
(_completion, _completion_stream, _acompletion, _acompletion_stream, _embedding, _aembedding, _image_generation),
|
|
ids=lambda route: route.__name__.lstrip("_"),
|
|
)
|
|
async def test_mapped_error_carries_the_upstream_error_response(
|
|
provider: str, route: Callable[[str], Awaitable[None]]
|
|
) -> None:
|
|
with pytest.raises(litellm.BadRequestError) as raised:
|
|
await route(provider)
|
|
|
|
assert raised.value.response.text == _UPSTREAM_ERROR_BODY
|
|
assert raised.value.response.json() == json.loads(_UPSTREAM_ERROR_BODY)
|
|
assert raised.value.response.headers["x-request-id"] == "req_upstream_400"
|
|
assert raised.value.response.headers["x-upstream-trace"] == "trace-7"
|
|
|
|
|
|
_UPSTREAM_SERVER_ERROR_BODY: Final = (
|
|
'{"error":{"message":"The server had an error while processing your request.","type":"server_error",'
|
|
'"param":null,"code":null}}'
|
|
)
|
|
_CHUNKED_UPSTREAM_HEADERS: Final = {
|
|
"content-type": "application/json",
|
|
"transfer-encoding": "chunked",
|
|
"x-request-id": "req_upstream_5xx",
|
|
}
|
|
|
|
|
|
def _chunked_upstream_client(status_code: int, content_encoding: Literal["gzip"] | None) -> httpx.AsyncClient:
|
|
body: Final = _UPSTREAM_SERVER_ERROR_BODY.encode()
|
|
encoding_headers: Final = {"content-encoding": content_encoding} if content_encoding else {}
|
|
return httpx.AsyncClient(
|
|
transport=httpx.MockTransport(
|
|
lambda _request: httpx.Response(
|
|
status_code,
|
|
headers={**_CHUNKED_UPSTREAM_HEADERS, **encoding_headers},
|
|
stream=httpx.ByteStream(gzip.compress(body) if content_encoding else body),
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("provider", ("openai", "litellm_proxy"))
|
|
@pytest.mark.parametrize("stream", (False, True))
|
|
@pytest.mark.parametrize(
|
|
("status_code", "mapped_error"),
|
|
(
|
|
(429, litellm.RateLimitError),
|
|
(500, litellm.InternalServerError),
|
|
(502, litellm.BadGatewayError),
|
|
(503, litellm.ServiceUnavailableError),
|
|
),
|
|
)
|
|
@pytest.mark.parametrize("content_encoding", (None, "gzip"))
|
|
async def test_retryable_error_keeps_the_decoded_upstream_body_and_its_headers(
|
|
provider: str,
|
|
stream: bool,
|
|
status_code: int,
|
|
mapped_error: type[Exception],
|
|
content_encoding: Literal["gzip"] | None,
|
|
) -> None:
|
|
client: Final = AsyncOpenAI(
|
|
api_key="transport-only",
|
|
base_url=_UPSTREAM_BASE,
|
|
max_retries=0,
|
|
http_client=_chunked_upstream_client(status_code, content_encoding),
|
|
)
|
|
|
|
with pytest.raises(mapped_error) as raised:
|
|
await litellm.acompletion(messages=_MESSAGES, client=client, stream=stream, **_upstream(provider, "gpt-5.6"))
|
|
|
|
error_response: Final = getattr(raised.value, "response")
|
|
assert error_response.status_code == status_code
|
|
assert error_response.text == _UPSTREAM_SERVER_ERROR_BODY
|
|
assert error_response.json() == json.loads(_UPSTREAM_SERVER_ERROR_BODY)
|
|
assert dict(error_response.headers) == {
|
|
**_CHUNKED_UPSTREAM_HEADERS,
|
|
**({"content-encoding": content_encoding} if content_encoding else {}),
|
|
}
|
|
|
|
|
|
_REFUSAL_BODY: Final = '{"error":{"message":"upstream refused","type":"upstream_refusal","param":null,"code":null}}'
|
|
_GATEWAY_TIMEOUT_BODY: Final = (
|
|
'{"error":{"message":"litellm.Timeout: APITimeoutError - Request timed out.","type":"invalid_request_error",'
|
|
'"param":null,"code":"408"}}'
|
|
)
|
|
_PROVIDER_KEY: Final = "sk-provider-secret"
|
|
_QUERY_SECRET: Final = "query-secret"
|
|
|
|
|
|
@pytest.mark.parametrize("provider", ("openai", "litellm_proxy"))
|
|
@pytest.mark.parametrize(
|
|
("status_code", "mapped_error", "body"),
|
|
(
|
|
(400, litellm.BadRequestError, _REFUSAL_BODY),
|
|
(403, litellm.APIError, _REFUSAL_BODY),
|
|
(408, litellm.Timeout, _REFUSAL_BODY),
|
|
(408, litellm.Timeout, _GATEWAY_TIMEOUT_BODY),
|
|
(409, litellm.APIError, _REFUSAL_BODY),
|
|
(413, litellm.APIError, _REFUSAL_BODY),
|
|
(429, litellm.RateLimitError, _REFUSAL_BODY),
|
|
(500, litellm.InternalServerError, _REFUSAL_BODY),
|
|
(504, litellm.Timeout, _REFUSAL_BODY),
|
|
),
|
|
)
|
|
def test_mapped_error_keeps_the_upstream_body_without_the_provider_credentials(
|
|
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"}
|
|
)
|
|
)
|
|
),
|
|
)
|
|
|
|
with pytest.raises(mapped_error) as raised:
|
|
litellm.completion(
|
|
model=f"{provider}/gpt-5.6",
|
|
messages=_MESSAGES,
|
|
client=client,
|
|
api_base=_UPSTREAM_BASE,
|
|
api_key=_PROVIDER_KEY,
|
|
num_retries=0,
|
|
)
|
|
|
|
error_response: Final = getattr(raised.value, "response")
|
|
assert error_response.status_code == status_code
|
|
assert error_response.text == body
|
|
assert error_response.json() == json.loads(body)
|
|
assert str(error_response.request.url) == f"{_UPSTREAM_BASE}/chat/completions"
|
|
for exposed in (error_response.request, getattr(raised.value, "request")):
|
|
assert _QUERY_SECRET not in str(exposed.url)
|
|
assert _PROVIDER_KEY not in str(exposed.headers)
|
|
assert exposed.content == b""
|
|
|
|
|
|
def _time_out(request: httpx.Request) -> httpx.Response:
|
|
raise httpx.ReadTimeout("no reply", request=request)
|
|
|
|
|
|
async def _sync_completion_timing_out(provider: str) -> None:
|
|
litellm.completion(
|
|
model=f"{provider}/gpt-5.6",
|
|
messages=_MESSAGES,
|
|
client=OpenAI(
|
|
api_key=_PROVIDER_KEY,
|
|
base_url=_UPSTREAM_BASE,
|
|
max_retries=0,
|
|
http_client=httpx.Client(transport=httpx.MockTransport(_time_out)),
|
|
),
|
|
api_base=_UPSTREAM_BASE,
|
|
api_key=_PROVIDER_KEY,
|
|
num_retries=0,
|
|
)
|
|
|
|
|
|
async def _async_stream_timing_out(provider: str) -> None:
|
|
stream: Final = await litellm.acompletion(
|
|
model=f"{provider}/gpt-5.6",
|
|
messages=_MESSAGES,
|
|
stream=True,
|
|
client=AsyncOpenAI(
|
|
api_key=_PROVIDER_KEY,
|
|
base_url=_UPSTREAM_BASE,
|
|
max_retries=0,
|
|
http_client=httpx.AsyncClient(transport=httpx.MockTransport(_time_out)),
|
|
),
|
|
api_base=_UPSTREAM_BASE,
|
|
api_key=_PROVIDER_KEY,
|
|
num_retries=0,
|
|
)
|
|
async for _ in stream:
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("provider", ("openai", "litellm_proxy"))
|
|
@pytest.mark.parametrize("call", (_sync_completion_timing_out, _async_stream_timing_out))
|
|
async def test_timeout_before_any_reply_has_no_upstream_response(
|
|
provider: str, call: Callable[[str], Awaitable[None]]
|
|
) -> None:
|
|
with pytest.raises(litellm.Timeout) as raised:
|
|
await call(provider)
|
|
|
|
assert getattr(raised.value, "response", None) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acompletion_streams_text_deltas_over_injected_transport(
|
|
http_backend: ModuleType, sdk_provider: str, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
outbound: Final = asyncio.Queue()
|
|
|
|
def chunk(delta: dict, finish: str | None) -> bytes:
|
|
body: Final = {
|
|
"id": "chatcmpl-smoke",
|
|
"object": "chat.completion.chunk",
|
|
"created": 0,
|
|
"model": "sdk-compat",
|
|
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
|
|
}
|
|
return f"data: {json.dumps(body)}\n\n".encode()
|
|
|
|
def respond(request: _Request) -> httpx.Response | SDKResponse:
|
|
outbound.put_nowait(json.loads(request.content))
|
|
usage: Final = {
|
|
"id": "chatcmpl-smoke",
|
|
"object": "chat.completion.chunk",
|
|
"created": 0,
|
|
"model": "sdk-compat",
|
|
"choices": [],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
|
}
|
|
content: Final = b"".join(
|
|
(
|
|
chunk({"role": "assistant", "content": "Hel"}, None),
|
|
chunk({"content": "lo"}, "stop"),
|
|
f"data: {json.dumps(usage)}\n\n".encode(),
|
|
b"data: [DONE]\n\n",
|
|
)
|
|
)
|
|
return http_backend.Response(200, headers={"content-type": "text/event-stream"}, content=content)
|
|
|
|
async with http_backend.AsyncClient(transport=http_backend.MockTransport(respond)) as http_client:
|
|
monkeypatch.setattr(litellm, "aclient_session", http_client)
|
|
stream: Final = await litellm.acompletion(
|
|
model=f"{sdk_provider}/sdk-compat",
|
|
api_key=f"{sdk_provider}-key",
|
|
api_base=f"https://{sdk_provider}.example/v1",
|
|
messages=[{"role": "user", "content": "smoke-stream-request"}],
|
|
stream=True,
|
|
num_retries=0,
|
|
max_retries=0,
|
|
)
|
|
chunks: Final = []
|
|
|
|
async def drain() -> None:
|
|
async for part in stream:
|
|
chunks.append(part)
|
|
|
|
await asyncio.wait_for(drain(), timeout=10)
|
|
request: Final = await asyncio.wait_for(outbound.get(), timeout=10)
|
|
assert request["stream"] is True
|
|
assert outbound.empty()
|
|
assert (
|
|
"".join(part.choices[0].delta.content or "" for part in chunks if part.choices and part.choices[0].delta)
|
|
== "Hello"
|
|
)
|
|
last_finish: Final = next(
|
|
part.choices[0].finish_reason for part in reversed(chunks) if part.choices and part.choices[0].finish_reason
|
|
)
|
|
assert last_finish == "stop"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acompletion_streams_tool_call_arguments_over_injected_transport(http_backend: ModuleType):
|
|
outbound: Final = asyncio.Queue()
|
|
tools: Final = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"description": "Look up weather for a city",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"city": {"type": "string"}},
|
|
"required": ["city"],
|
|
},
|
|
},
|
|
}
|
|
]
|
|
|
|
def chunk(delta: dict, finish: str | None) -> bytes:
|
|
body: Final = {
|
|
"id": "chatcmpl-smoke",
|
|
"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()
|
|
|
|
def respond(request: _Request) -> httpx.Response | SDKResponse:
|
|
assert request.extensions["timeout"] == {"connect": 1, "read": None, "write": 2, "pool": 3}
|
|
outbound.put_nowait(json.loads(request.content))
|
|
content: Final = b"".join(
|
|
(
|
|
chunk(
|
|
{
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "call-1",
|
|
"type": "function",
|
|
"function": {"name": "get_weather", "arguments": ""},
|
|
}
|
|
]
|
|
},
|
|
None,
|
|
),
|
|
chunk({"tool_calls": [{"index": 0, "function": {"arguments": '{"city":'}}]}, None),
|
|
chunk({"tool_calls": [{"index": 0, "function": {"arguments": '"Paris"}'}}]}, "tool_calls"),
|
|
b"data: [DONE]\n\n",
|
|
)
|
|
)
|
|
return http_backend.Response(200, headers={"content-type": "text/event-stream"}, content=content)
|
|
|
|
async with http_backend.AsyncClient(transport=http_backend.MockTransport(respond)) as http_client:
|
|
client: Final = AsyncOpenAI(api_key="transport-only", http_client=http_client)
|
|
messages: Final = [{"role": "user", "content": "weather in Paris"}]
|
|
stream: Final = await litellm.acompletion(
|
|
model="openai/gpt-4o",
|
|
api_key="transport-only",
|
|
client=client,
|
|
messages=messages,
|
|
tools=tools,
|
|
stream=True,
|
|
timeout=http_backend.Timeout(connect=1, read=None, write=2, pool=3),
|
|
num_retries=0,
|
|
max_retries=0,
|
|
)
|
|
chunks: Final = []
|
|
|
|
async def drain() -> None:
|
|
async for part in stream:
|
|
chunks.append(part)
|
|
|
|
await asyncio.wait_for(drain(), timeout=10)
|
|
request: Final = await asyncio.wait_for(outbound.get(), timeout=10)
|
|
assert request["stream"] is True
|
|
assert request["tools"][0]["function"]["name"] == "get_weather"
|
|
assert outbound.empty()
|
|
rebuilt: Final = litellm.stream_chunk_builder(chunks, messages=messages)
|
|
tool_call: Final = rebuilt.choices[0].message.tool_calls[0]
|
|
assert tool_call.id == "call-1"
|
|
assert tool_call.function.name == "get_weather"
|
|
assert json.loads(tool_call.function.arguments) == {"city": "Paris"}
|
|
assert rebuilt.choices[0].finish_reason == "tool_calls"
|
|
|
|
|
|
def _openai_sse_chunk(chunk_id: str, model: str, choices: list[JsonValue]) -> bytes:
|
|
body: Final[dict[str, JsonValue]] = {
|
|
"id": chunk_id,
|
|
"object": "chat.completion.chunk",
|
|
"created": 0,
|
|
"model": model,
|
|
"choices": choices,
|
|
}
|
|
return f"data: {json.dumps(body)}\n\n".encode()
|
|
|
|
|
|
def _openai_sse_usage_chunk(chunk_id: str, model: str, *, prompt_tokens: int, completion_tokens: int) -> bytes:
|
|
body: Final[dict[str, JsonValue]] = {
|
|
"id": chunk_id,
|
|
"object": "chat.completion.chunk",
|
|
"created": 0,
|
|
"model": model,
|
|
"choices": [],
|
|
"usage": {
|
|
"prompt_tokens": prompt_tokens,
|
|
"completion_tokens": completion_tokens,
|
|
"total_tokens": prompt_tokens + completion_tokens,
|
|
},
|
|
}
|
|
return f"data: {json.dumps(body)}\n\n".encode()
|
|
|
|
|
|
@pytest.mark.respx(assert_all_called=True)
|
|
@pytest.mark.parametrize("model", ["o1", "o3-mini"])
|
|
def test_o1_parallel_tool_calls_are_dropped_for_unsupported_models(respx_mock: respx.MockRouter, model: str) -> None:
|
|
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "chatcmpl-o-series",
|
|
"object": "chat.completion",
|
|
"created": 0,
|
|
"model": model,
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "done"},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
},
|
|
)
|
|
)
|
|
|
|
response: Final = litellm.completion(
|
|
model=model,
|
|
messages=[{"role": "user", "content": "foo"}],
|
|
parallel_tool_calls=True,
|
|
drop_params=True,
|
|
api_key="sk-openai-test",
|
|
num_retries=0,
|
|
max_retries=0,
|
|
)
|
|
request_body: Final = json.loads(route.calls[0].request.content)
|
|
|
|
assert request_body == {"model": model, "messages": [{"role": "user", "content": "foo"}]}
|
|
assert response.choices[0].message.content == "done"
|
|
|
|
|
|
@pytest.mark.respx(assert_all_called=True)
|
|
def test_prediction_parameter_participates_in_completion_cache_key(
|
|
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "cache", Cache(type=LiteLLMCacheType.LOCAL))
|
|
|
|
def respond(request: httpx.Request) -> httpx.Response:
|
|
request_body: Final = json.loads(request.content)
|
|
prediction_content: Final = request_body["prediction"]["content"]
|
|
response_id: Final = (
|
|
"chatcmpl-prediction-one" if prediction_content == "prediction one" else "chatcmpl-prediction-two"
|
|
)
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": response_id,
|
|
"object": "chat.completion",
|
|
"created": 0,
|
|
"model": "gpt-4o",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": prediction_content},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
},
|
|
)
|
|
|
|
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(side_effect=respond)
|
|
messages: Final = [{"role": "user", "content": "replace the username"}]
|
|
first_prediction: Final = {"type": "content", "content": "prediction one"}
|
|
second_prediction: Final = {"type": "content", "content": "prediction two"}
|
|
|
|
first: Final = litellm.completion(
|
|
model="gpt-4o",
|
|
messages=messages,
|
|
prediction=first_prediction,
|
|
caching=True,
|
|
api_key="sk-openai-test",
|
|
num_retries=0,
|
|
max_retries=0,
|
|
)
|
|
cached: Final = litellm.completion(
|
|
model="gpt-4o",
|
|
messages=messages,
|
|
prediction=first_prediction,
|
|
caching=True,
|
|
api_key="sk-openai-test",
|
|
num_retries=0,
|
|
max_retries=0,
|
|
)
|
|
distinct: Final = litellm.completion(
|
|
model="gpt-4o",
|
|
messages=messages,
|
|
prediction=second_prediction,
|
|
caching=True,
|
|
api_key="sk-openai-test",
|
|
num_retries=0,
|
|
max_retries=0,
|
|
)
|
|
request_bodies: Final = tuple(json.loads(call.request.content) for call in route.calls)
|
|
|
|
assert first.id == cached.id == "chatcmpl-prediction-one"
|
|
assert distinct.id == "chatcmpl-prediction-two"
|
|
assert route.call_count == 2
|
|
assert request_bodies == (
|
|
{"model": "gpt-4o", "messages": messages, "prediction": first_prediction},
|
|
{"model": "gpt-4o", "messages": messages, "prediction": second_prediction},
|
|
)
|
|
|
|
|
|
class _OpenAIFunction(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
description: str | None = None
|
|
name: str
|
|
parameters: dict[str, JsonValue] | None = None
|
|
|
|
|
|
class _OpenAITool(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
|
|
type: Literal["function"]
|
|
function: _OpenAIFunction
|
|
|
|
|
|
@pytest.mark.respx(assert_all_called=True)
|
|
def test_openai_tool_calling_sends_schema_and_parses_function_call(respx_mock: respx.MockRouter) -> None:
|
|
tools: Final = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_stock_price",
|
|
"description": "Get the current stock price for a given ticker symbol.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"ticker": {"type": "string", "description": "The stock ticker symbol."}},
|
|
"required": ["ticker"],
|
|
},
|
|
},
|
|
}
|
|
]
|
|
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "chatcmpl-tool-call",
|
|
"object": "chat.completion",
|
|
"created": 0,
|
|
"model": "gpt-4.1",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call-tsla",
|
|
"type": "function",
|
|
"function": {"name": "get_stock_price", "arguments": '{"ticker":"TSLA"}'},
|
|
}
|
|
],
|
|
},
|
|
"finish_reason": "tool_calls",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 8, "completion_tokens": 3, "total_tokens": 11},
|
|
},
|
|
)
|
|
)
|
|
|
|
response: Final = litellm.completion(
|
|
model="openai/gpt-4.1",
|
|
messages=[{"role": "user", "content": [{"type": "text", "text": "What is TSLA stock price at today?"}]}],
|
|
temperature=0.5,
|
|
max_tokens=1600,
|
|
tools=[_OpenAITool.model_validate(tool) for tool in tools],
|
|
api_key="sk-openai-test",
|
|
num_retries=0,
|
|
max_retries=0,
|
|
)
|
|
request_body: Final = json.loads(route.calls[0].request.content)
|
|
tool_call: Final = response.choices[0].message.tool_calls[0]
|
|
|
|
assert request_body == {
|
|
"model": "gpt-4.1",
|
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "What is TSLA stock price at today?"}]}],
|
|
"temperature": 0.5,
|
|
"max_tokens": 1600,
|
|
"tools": tools,
|
|
}
|
|
assert (tool_call.id, tool_call.function.name, tool_call.function.arguments) == (
|
|
"call-tsla",
|
|
"get_stock_price",
|
|
'{"ticker":"TSLA"}',
|
|
)
|
|
assert response.choices[0].finish_reason == "tool_calls"
|
|
|
|
|
|
@pytest.mark.respx(assert_all_called=True)
|
|
@pytest.mark.asyncio
|
|
async def test_openai_via_gemini_streaming_bridge_translates_openai_chunks(
|
|
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
stream_body: Final = b"".join(
|
|
(
|
|
_openai_sse_chunk(
|
|
"chatcmpl-bridge",
|
|
"gpt-3.5-turbo",
|
|
[{"index": 0, "delta": {"role": "assistant", "content": "A starship "}, "finish_reason": None}],
|
|
),
|
|
_openai_sse_chunk(
|
|
"chatcmpl-bridge",
|
|
"gpt-3.5-turbo",
|
|
[{"index": 0, "delta": {"content": "crossed the sky."}, "finish_reason": None}],
|
|
),
|
|
_openai_sse_chunk("chatcmpl-bridge", "gpt-3.5-turbo", [{"index": 0, "delta": {}, "finish_reason": "stop"}]),
|
|
_openai_sse_usage_chunk("chatcmpl-bridge", "gpt-3.5-turbo", prompt_tokens=12, completion_tokens=6),
|
|
b"data: [DONE]\n\n",
|
|
)
|
|
)
|
|
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
|
return_value=httpx.Response(200, headers={"content-type": "text/event-stream"}, content=stream_body)
|
|
)
|
|
router: Final = litellm.Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "openai/gpt-3.5-turbo",
|
|
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "sk-openai-test"},
|
|
}
|
|
]
|
|
)
|
|
|
|
stream: Final = await router.agenerate_content_stream(
|
|
model="openai/gpt-3.5-turbo",
|
|
contents=[{"parts": [{"text": "Write a long story about space exploration"}], "role": "user"}],
|
|
generationConfig={"maxOutputTokens": 500},
|
|
)
|
|
chunks: Final = tuple([json.loads(chunk.removeprefix(b"data: ")) async for chunk in stream])
|
|
|
|
assert json.loads(route.calls[0].request.content) == {
|
|
"messages": [{"role": "user", "content": "Write a long story about space exploration"}],
|
|
"model": "gpt-3.5-turbo",
|
|
"max_tokens": 500,
|
|
"stream": True,
|
|
"stream_options": {"include_usage": True},
|
|
}
|
|
assert chunks == (
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {"parts": [{"text": "A starship "}], "role": "model"},
|
|
"finishReason": None,
|
|
"index": 0,
|
|
"safetyRatings": [],
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {"parts": [{"text": "crossed the sky."}], "role": "model"},
|
|
"finishReason": None,
|
|
"index": 0,
|
|
"safetyRatings": [],
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"candidates": [
|
|
{"content": {"parts": [], "role": "model"}, "finishReason": "STOP", "index": 0, "safetyRatings": []}
|
|
],
|
|
"usageMetadata": {"promptTokenCount": 12, "candidatesTokenCount": 6, "totalTokenCount": 18},
|
|
},
|
|
)
|
|
|
|
|
|
@pytest.mark.respx(assert_all_called=True)
|
|
def test_streaming_content_with_n_greater_than_one_preserves_choice_indices(
|
|
respx_mock: respx.MockRouter,
|
|
) -> None:
|
|
stream_body: Final = b"".join(
|
|
(
|
|
_openai_sse_chunk(
|
|
"chatcmpl-n-content",
|
|
"gpt-4o",
|
|
[{"index": 0, "delta": {"role": "assistant", "content": "Hello"}, "finish_reason": None}],
|
|
),
|
|
_openai_sse_chunk(
|
|
"chatcmpl-n-content",
|
|
"gpt-4o",
|
|
[{"index": 1, "delta": {"role": "assistant", "content": "Hi"}, "finish_reason": None}],
|
|
),
|
|
_openai_sse_chunk("chatcmpl-n-content", "gpt-4o", [{"index": 0, "delta": {}, "finish_reason": "stop"}]),
|
|
_openai_sse_chunk("chatcmpl-n-content", "gpt-4o", [{"index": 1, "delta": {}, "finish_reason": "stop"}]),
|
|
b"data: [DONE]\n\n",
|
|
)
|
|
)
|
|
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
|
return_value=httpx.Response(200, headers={"content-type": "text/event-stream"}, content=stream_body)
|
|
)
|
|
response: Final = litellm.completion(
|
|
model="openai/gpt-4o",
|
|
messages=[{"role": "user", "content": "Say hello in one word"}],
|
|
stream=True,
|
|
n=2,
|
|
max_tokens=10,
|
|
api_key="sk-openai-test",
|
|
num_retries=0,
|
|
max_retries=0,
|
|
)
|
|
chunks: Final = tuple(response)
|
|
request_body: Final = json.loads(route.calls[0].request.content)
|
|
indices: Final = tuple(chunk.choices[0].index for chunk in chunks if chunk.choices)
|
|
content_by_index: Final = {
|
|
index: "".join(
|
|
chunk.choices[0].delta.content or ""
|
|
for chunk in chunks
|
|
if chunk.choices and chunk.choices[0].index == index
|
|
)
|
|
for index in (0, 1)
|
|
}
|
|
|
|
assert route.call_count == 1
|
|
assert request_body == {
|
|
"model": "gpt-4o",
|
|
"messages": [{"role": "user", "content": "Say hello in one word"}],
|
|
"stream": True,
|
|
"n": 2,
|
|
"max_tokens": 10,
|
|
"stream_options": {"include_usage": True},
|
|
}
|
|
assert frozenset(indices) == frozenset({0, 1})
|
|
assert content_by_index == {0: "Hello", 1: "Hi"}
|
|
|
|
|
|
@pytest.mark.respx(assert_all_called=True)
|
|
def test_streaming_tool_calls_with_n_greater_than_one_preserves_choice_indices(
|
|
respx_mock: respx.MockRouter,
|
|
) -> None:
|
|
tools: Final = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_current_weather",
|
|
"description": "Get the current weather in a given location",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"location": {"type": "string"},
|
|
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
|
|
},
|
|
"required": ["location", "unit"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
}
|
|
]
|
|
stream_body: Final = b"".join(
|
|
(
|
|
_openai_sse_chunk(
|
|
"chatcmpl-n-tools",
|
|
"gpt-4o",
|
|
[
|
|
{
|
|
"index": 0,
|
|
"delta": {
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "call-0",
|
|
"type": "function",
|
|
"function": {"name": "get_current_weather", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
"finish_reason": None,
|
|
}
|
|
],
|
|
),
|
|
_openai_sse_chunk(
|
|
"chatcmpl-n-tools",
|
|
"gpt-4o",
|
|
[{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
|
|
),
|
|
_openai_sse_chunk(
|
|
"chatcmpl-n-tools",
|
|
"gpt-4o",
|
|
[
|
|
{
|
|
"index": 1,
|
|
"delta": {
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "call-1",
|
|
"type": "function",
|
|
"function": {"name": "get_current_weather", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
"finish_reason": None,
|
|
}
|
|
],
|
|
),
|
|
_openai_sse_chunk(
|
|
"chatcmpl-n-tools",
|
|
"gpt-4o",
|
|
[{"index": 1, "delta": {}, "finish_reason": "tool_calls"}],
|
|
),
|
|
_openai_sse_chunk(
|
|
"chatcmpl-n-tools",
|
|
"gpt-4o",
|
|
[
|
|
{
|
|
"index": 2,
|
|
"delta": {
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "call-2",
|
|
"type": "function",
|
|
"function": {"name": "get_current_weather", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
"finish_reason": None,
|
|
}
|
|
],
|
|
),
|
|
_openai_sse_chunk(
|
|
"chatcmpl-n-tools",
|
|
"gpt-4o",
|
|
[{"index": 2, "delta": {}, "finish_reason": "tool_calls"}],
|
|
),
|
|
b"data: [DONE]\n\n",
|
|
)
|
|
)
|
|
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
|
return_value=httpx.Response(200, headers={"content-type": "text/event-stream"}, content=stream_body)
|
|
)
|
|
response: Final = litellm.completion(
|
|
model="openai/gpt-4o",
|
|
messages=[{"role": "user", "content": "What is the weather in San Francisco?"}],
|
|
tools=tools,
|
|
stream=True,
|
|
n=3,
|
|
api_key="sk-openai-test",
|
|
num_retries=0,
|
|
max_retries=0,
|
|
)
|
|
chunks: Final = tuple(response)
|
|
request_body: Final = json.loads(route.calls[0].request.content)
|
|
indices: Final = tuple(chunk.choices[0].index for chunk in chunks if chunk.choices)
|
|
|
|
assert route.call_count == 1
|
|
assert request_body == {
|
|
"model": "gpt-4o",
|
|
"messages": [{"role": "user", "content": "What is the weather in San Francisco?"}],
|
|
"tools": tools,
|
|
"stream": True,
|
|
"n": 3,
|
|
"stream_options": {"include_usage": True},
|
|
}
|
|
assert frozenset(indices) == frozenset({0, 1, 2})
|
|
|
|
|
|
_PROVIDER_HEADERS: Final = {"x-request-id": "req_openai", "x-ratelimit-remaining-requests": "41"}
|
|
|
|
|
|
def _image_generation_transport() -> httpx.MockTransport:
|
|
return httpx.MockTransport(
|
|
lambda request: httpx.Response(
|
|
200, json={"created": 1, "data": [{"b64_json": "abc"}]}, headers=_PROVIDER_HEADERS
|
|
)
|
|
)
|
|
|
|
|
|
def _speech_transport() -> httpx.MockTransport:
|
|
return httpx.MockTransport(
|
|
lambda request: httpx.Response(
|
|
200, content=b"audio-bytes", headers={**_PROVIDER_HEADERS, "content-type": "audio/mpeg"}
|
|
)
|
|
)
|
|
|
|
|
|
def _assert_provider_headers_recorded(response) -> None:
|
|
assert response._hidden_params["headers"]["x-request-id"] == "req_openai"
|
|
assert response._hidden_params["additional_headers"]["llm_provider-x-request-id"] == "req_openai"
|
|
assert response._hidden_params["additional_headers"]["x-ratelimit-remaining-requests"] == "41"
|
|
|
|
|
|
def _image_generation_kwargs() -> dict:
|
|
return {
|
|
"model": "gpt-image-2",
|
|
"prompt": "a cat",
|
|
"timeout": 10,
|
|
"optional_params": {},
|
|
"logging_obj": Mock(),
|
|
"api_key": "transport-only",
|
|
"model_response": ImageResponse(),
|
|
}
|
|
|
|
|
|
def test_image_generation_records_provider_response_headers():
|
|
with httpx.Client(transport=_image_generation_transport()) as http_client:
|
|
response = OpenAIChatCompletion().image_generation(
|
|
client=OpenAI(api_key="transport-only", http_client=http_client), **_image_generation_kwargs()
|
|
)
|
|
|
|
_assert_provider_headers_recorded(response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_aimage_generation_records_provider_response_headers():
|
|
async with httpx.AsyncClient(transport=_image_generation_transport()) as http_client:
|
|
response = await OpenAIChatCompletion().image_generation(
|
|
client=AsyncOpenAI(api_key="transport-only", http_client=http_client),
|
|
aimg_generation=True,
|
|
**_image_generation_kwargs(),
|
|
)
|
|
|
|
_assert_provider_headers_recorded(response)
|
|
|
|
|
|
def _audio_speech_kwargs() -> dict:
|
|
return {
|
|
"model": "gpt-4o-mini-tts",
|
|
"input": "hello",
|
|
"voice": "alloy",
|
|
"optional_params": {},
|
|
"api_key": "transport-only",
|
|
"api_base": None,
|
|
"organization": None,
|
|
"project": None,
|
|
"max_retries": 0,
|
|
"timeout": 10,
|
|
"logging_obj": Mock(),
|
|
}
|
|
|
|
|
|
def test_audio_speech_records_provider_response_headers():
|
|
with httpx.Client(transport=_speech_transport()) as http_client:
|
|
response = OpenAIChatCompletion().audio_speech(
|
|
client=OpenAI(api_key="transport-only", http_client=http_client), **_audio_speech_kwargs()
|
|
)
|
|
|
|
_assert_provider_headers_recorded(response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_audio_speech_records_provider_response_headers():
|
|
async with httpx.AsyncClient(transport=_speech_transport()) as http_client:
|
|
response = await OpenAIChatCompletion().audio_speech(
|
|
client=AsyncOpenAI(api_key="transport-only", http_client=http_client),
|
|
aspeech=True,
|
|
**_audio_speech_kwargs(),
|
|
)
|
|
|
|
_assert_provider_headers_recorded(response)
|
|
|
|
|
|
@pytest.fixture()
|
|
def _vcr_outcome_gate(request, vcr):
|
|
install_live_call_probe(request, vcr)
|
|
yield
|
|
record_vcr_outcome(request, vcr)
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def isolate_litellm_state():
|
|
"""
|
|
Per-function isolation fixture.
|
|
|
|
Resets litellm globals to their true defaults before each test and
|
|
restores them afterward, so tests don't leak side effects.
|
|
Works safely under pytest-xdist parallel execution.
|
|
"""
|
|
original_state = {}
|
|
for attr in (
|
|
"callbacks",
|
|
"success_callback",
|
|
"failure_callback",
|
|
"_async_success_callback",
|
|
"_async_failure_callback",
|
|
):
|
|
if hasattr(litellm, attr):
|
|
val = getattr(litellm, attr)
|
|
original_state[attr] = val.copy() if val else []
|
|
for attr in ("pre_call_rules", "post_call_rules"):
|
|
if hasattr(litellm, attr):
|
|
val = getattr(litellm, attr)
|
|
original_state[attr] = val.copy() if val else []
|
|
for attr in _SCALAR_DEFAULTS:
|
|
if hasattr(litellm, attr):
|
|
original_state[attr] = getattr(litellm, attr)
|
|
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
for attr in (
|
|
"callbacks",
|
|
"success_callback",
|
|
"failure_callback",
|
|
"_async_success_callback",
|
|
"_async_failure_callback",
|
|
"pre_call_rules",
|
|
"post_call_rules",
|
|
):
|
|
if hasattr(litellm, attr):
|
|
setattr(litellm, attr, [])
|
|
for attr, default_val in _SCALAR_DEFAULTS.items():
|
|
if hasattr(litellm, attr):
|
|
setattr(litellm, attr, default_val)
|
|
yield
|
|
asyncio.run(GLOBAL_LOGGING_WORKER.clear_queue())
|
|
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
for attr, original_value in original_state.items():
|
|
if hasattr(litellm, attr):
|
|
setattr(litellm, attr, original_value)
|
|
_invalidate_model_cost_lowercase_map()
|
|
|
|
|
|
_SCALAR_DEFAULTS = {
|
|
"num_retries": getattr(litellm, "num_retries", None),
|
|
"num_retries_per_request": getattr(litellm, "num_retries_per_request", None),
|
|
"request_timeout": getattr(litellm, "request_timeout", None),
|
|
"set_verbose": getattr(litellm, "set_verbose", False),
|
|
"cache": getattr(litellm, "cache", None),
|
|
"allowed_fails": getattr(litellm, "allowed_fails", 3),
|
|
"default_fallbacks": getattr(litellm, "default_fallbacks", None),
|
|
"enable_azure_ad_token_refresh": getattr(litellm, "enable_azure_ad_token_refresh", None),
|
|
"tag_budget_config": getattr(litellm, "tag_budget_config", None),
|
|
"model_cost": getattr(litellm, "model_cost", None),
|
|
"token_counter": getattr(litellm, "token_counter", None),
|
|
"disable_aiohttp_transport": getattr(litellm, "disable_aiohttp_transport", False),
|
|
"force_ipv4": getattr(litellm, "force_ipv4", False),
|
|
"drop_params": getattr(litellm, "drop_params", None),
|
|
"modify_params": getattr(litellm, "modify_params", False),
|
|
"api_base": getattr(litellm, "api_base", None),
|
|
"api_key": getattr(litellm, "api_key", None),
|
|
}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def setup_and_teardown():
|
|
"""
|
|
Module-scoped setup. Reloads litellm only in single-process mode
|
|
(skipped under xdist to avoid cross-worker interference).
|
|
"""
|
|
import litellm
|
|
|
|
worker_id = os.environ.get("PYTEST_XDIST_WORKER", None)
|
|
if worker_id is None:
|
|
importlib.reload(litellm)
|
|
try:
|
|
if hasattr(litellm, "proxy") and hasattr(litellm.proxy, "proxy_server"):
|
|
import litellm.proxy.proxy_server
|
|
|
|
importlib.reload(litellm.proxy.proxy_server)
|
|
except Exception:
|
|
pass
|
|
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
|
litellm.in_memory_llm_clients_cache.flush_cache()
|
|
yield
|
|
|
|
|
|
ASSISTANT_INSTRUCTIONS = (
|
|
"You are a personal math tutor. When asked a question, write and run Python code to answer the question."
|
|
)
|
|
|
|
ASSISTANT_ID = "asst_test"
|
|
|
|
THREAD_ID = "thread_test"
|
|
|
|
MESSAGE_ID = "msg_test"
|
|
|
|
RUN_ID = "run_test"
|
|
|
|
|
|
def _assistant(**overrides):
|
|
data = {
|
|
"id": ASSISTANT_ID,
|
|
"object": "assistant",
|
|
"created_at": 1,
|
|
"name": "Math Tutor",
|
|
"description": None,
|
|
"model": "gpt-4.1",
|
|
"instructions": ASSISTANT_INSTRUCTIONS,
|
|
"tools": [],
|
|
"metadata": {},
|
|
"top_p": 1.0,
|
|
"temperature": 1.0,
|
|
"response_format": "auto",
|
|
}
|
|
data.update(overrides)
|
|
return Assistant(**data)
|
|
|
|
|
|
def _thread(thread_id=THREAD_ID):
|
|
return Thread(id=thread_id, object="thread", created_at=1, metadata={})
|
|
|
|
|
|
def _message(thread_id=THREAD_ID):
|
|
return Message(
|
|
id=MESSAGE_ID,
|
|
object="thread.message",
|
|
created_at=1,
|
|
thread_id=thread_id,
|
|
role="user",
|
|
content=[
|
|
{
|
|
"type": "text",
|
|
"text": {"value": "Hey, how's it going?", "annotations": []},
|
|
}
|
|
],
|
|
assistant_id=None,
|
|
run_id=None,
|
|
attachments=[],
|
|
metadata={},
|
|
status="completed",
|
|
)
|
|
|
|
|
|
def _run(thread_id=THREAD_ID, assistant_id=ASSISTANT_ID):
|
|
return Run(
|
|
id=RUN_ID,
|
|
object="thread.run",
|
|
created_at=1,
|
|
assistant_id=assistant_id,
|
|
thread_id=thread_id,
|
|
status="completed",
|
|
started_at=1,
|
|
expires_at=None,
|
|
cancelled_at=None,
|
|
failed_at=None,
|
|
completed_at=1,
|
|
last_error=None,
|
|
model="gpt-4.1",
|
|
instructions=ASSISTANT_INSTRUCTIONS,
|
|
tools=[],
|
|
metadata={},
|
|
usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
required_action=None,
|
|
incomplete_details=None,
|
|
temperature=1.0,
|
|
top_p=1.0,
|
|
max_prompt_tokens=None,
|
|
max_completion_tokens=None,
|
|
truncation_strategy={"type": "auto", "last_messages": None},
|
|
response_format="auto",
|
|
tool_choice="auto",
|
|
parallel_tool_calls=True,
|
|
)
|
|
|
|
|
|
def _sync_page(data):
|
|
first_id = data[0].id if data else None
|
|
return SyncCursorPage(
|
|
data=data,
|
|
object="list",
|
|
first_id=first_id,
|
|
last_id=first_id,
|
|
has_more=False,
|
|
)
|
|
|
|
|
|
def _async_page(data):
|
|
first_id = data[0].id if data else None
|
|
return AsyncCursorPage(
|
|
data=data,
|
|
object="list",
|
|
first_id=first_id,
|
|
last_id=first_id,
|
|
has_more=False,
|
|
)
|
|
|
|
|
|
class _FakeAssistantEventHandler(AssistantEventHandler):
|
|
def until_done(self):
|
|
return None
|
|
|
|
|
|
class _FakeAsyncAssistantEventHandler(AsyncAssistantEventHandler):
|
|
async def until_done(self):
|
|
return None
|
|
|
|
|
|
class _FakeAssistantStream:
|
|
def __enter__(self):
|
|
return _FakeAssistantEventHandler()
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
|
|
class _FakeAsyncAssistantStream:
|
|
async def __aenter__(self):
|
|
return _FakeAsyncAssistantEventHandler()
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
|
|
class _SyncAssistants:
|
|
def list(self, **_kwargs):
|
|
return _sync_page([_assistant()])
|
|
|
|
def create(self, **kwargs):
|
|
return _assistant(**kwargs)
|
|
|
|
def delete(self, assistant_id):
|
|
return AssistantDeleted(id=assistant_id, object="assistant.deleted", deleted=True)
|
|
|
|
|
|
class _AsyncAssistants:
|
|
async def list(self, **_kwargs):
|
|
return _async_page([_assistant()])
|
|
|
|
async def create(self, **kwargs):
|
|
return _assistant(**kwargs)
|
|
|
|
async def delete(self, assistant_id):
|
|
return AssistantDeleted(id=assistant_id, object="assistant.deleted", deleted=True)
|
|
|
|
|
|
class _SyncMessages:
|
|
def create(self, thread_id, **_kwargs):
|
|
return _message(thread_id)
|
|
|
|
def list(self, thread_id):
|
|
return _sync_page([_message(thread_id)])
|
|
|
|
|
|
class _AsyncMessages:
|
|
async def create(self, thread_id, **_kwargs):
|
|
return _message(thread_id)
|
|
|
|
async def list(self, thread_id):
|
|
return _async_page([_message(thread_id)])
|
|
|
|
|
|
class _SyncRuns:
|
|
def create_and_poll(self, thread_id, assistant_id, **_kwargs):
|
|
return _run(thread_id=thread_id, assistant_id=assistant_id)
|
|
|
|
def stream(self, **_kwargs):
|
|
return _FakeAssistantStream()
|
|
|
|
|
|
class _AsyncRuns:
|
|
async def create_and_poll(self, thread_id, assistant_id, **_kwargs):
|
|
return _run(thread_id=thread_id, assistant_id=assistant_id)
|
|
|
|
def stream(self, **_kwargs):
|
|
return _FakeAsyncAssistantStream()
|
|
|
|
|
|
class _SyncThreads:
|
|
def __init__(self):
|
|
self.messages = _SyncMessages()
|
|
self.runs = _SyncRuns()
|
|
|
|
def create(self, **_kwargs):
|
|
return _thread()
|
|
|
|
def retrieve(self, thread_id):
|
|
return _thread(thread_id)
|
|
|
|
|
|
class _AsyncThreads:
|
|
def __init__(self):
|
|
self.messages = _AsyncMessages()
|
|
self.runs = _AsyncRuns()
|
|
|
|
async def create(self, **_kwargs):
|
|
return _thread()
|
|
|
|
async def retrieve(self, thread_id):
|
|
return _thread(thread_id)
|
|
|
|
|
|
class _FakeBeta:
|
|
def __init__(self, *, async_mode):
|
|
self.assistants = _AsyncAssistants() if async_mode else _SyncAssistants()
|
|
self.threads = _AsyncThreads() if async_mode else _SyncThreads()
|
|
|
|
|
|
class _FakeAssistantClient:
|
|
def __init__(self, *, async_mode):
|
|
self.beta = _FakeBeta(async_mode=async_mode)
|
|
|
|
|
|
@pytest.fixture
|
|
def assistant_client(sync_mode):
|
|
return _FakeAssistantClient(async_mode=not sync_mode)
|
|
|
|
|
|
def _request_data(provider, assistant_client, **kwargs):
|
|
data = {"custom_llm_provider": provider, "client": assistant_client, **kwargs}
|
|
if provider == "azure":
|
|
data.update(
|
|
{
|
|
"api_version": "2024-02-15-preview",
|
|
"api_base": "https://example.azure.test",
|
|
"api_key": "test-key",
|
|
}
|
|
)
|
|
return data
|
|
|
|
|
|
@pytest.mark.usefixtures("_vcr_outcome_gate", "isolate_litellm_state", "setup_and_teardown")
|
|
@pytest.mark.parametrize("provider", ["openai", "azure"])
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_get_assistants(provider, sync_mode, assistant_client):
|
|
data = _request_data(provider, assistant_client)
|
|
|
|
if sync_mode:
|
|
assistants = litellm.get_assistants(**data)
|
|
assert isinstance(assistants, SyncCursorPage)
|
|
else:
|
|
assistants = await litellm.aget_assistants(**data)
|
|
assert isinstance(assistants, AsyncCursorPage)
|
|
|
|
|
|
@pytest.mark.usefixtures("_vcr_outcome_gate", "isolate_litellm_state", "setup_and_teardown")
|
|
@pytest.mark.parametrize("provider", ["azure", "openai"])
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.asyncio()
|
|
async def test_create_delete_assistants(provider, sync_mode, assistant_client):
|
|
data = _request_data(
|
|
provider,
|
|
assistant_client,
|
|
model="gpt-4.1",
|
|
instructions=ASSISTANT_INSTRUCTIONS,
|
|
name="Math Tutor",
|
|
tools=[{"type": "code_interpreter"}],
|
|
)
|
|
|
|
if sync_mode:
|
|
assistant = litellm.create_assistants(**data)
|
|
assert isinstance(assistant, Assistant)
|
|
assert assistant.instructions == ASSISTANT_INSTRUCTIONS
|
|
assert assistant.id is not None
|
|
|
|
response = litellm.delete_assistant(
|
|
**_request_data(
|
|
provider,
|
|
assistant_client,
|
|
assistant_id=assistant.id,
|
|
)
|
|
)
|
|
assert response.id == assistant.id
|
|
else:
|
|
assistant = await litellm.acreate_assistants(**data)
|
|
assert isinstance(assistant, Assistant)
|
|
assert assistant.instructions == ASSISTANT_INSTRUCTIONS
|
|
assert assistant.id is not None
|
|
|
|
response = await litellm.adelete_assistant(
|
|
**_request_data(
|
|
provider,
|
|
assistant_client,
|
|
assistant_id=assistant.id,
|
|
)
|
|
)
|
|
assert response.id == assistant.id
|
|
|
|
|
|
async def _create_thread_litellm(sync_mode, provider, assistant_client) -> Thread:
|
|
message: MessageData = {"role": "user", "content": "Hey, how's it going?"} # type: ignore
|
|
data = _request_data(provider, assistant_client, message=[message])
|
|
|
|
if sync_mode:
|
|
new_thread = create_thread(**data)
|
|
else:
|
|
new_thread = await litellm.acreate_thread(**data)
|
|
|
|
assert isinstance(new_thread, Thread)
|
|
return new_thread
|
|
|
|
|
|
@pytest.mark.usefixtures("_vcr_outcome_gate", "isolate_litellm_state", "setup_and_teardown")
|
|
@pytest.mark.parametrize("provider", ["openai", "azure"])
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_create_thread_litellm(sync_mode, provider, assistant_client):
|
|
await _create_thread_litellm(sync_mode, provider, assistant_client)
|
|
|
|
|
|
@pytest.mark.usefixtures("_vcr_outcome_gate", "isolate_litellm_state", "setup_and_teardown")
|
|
@pytest.mark.parametrize("provider", ["openai", "azure"])
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_get_thread_litellm(provider, sync_mode, assistant_client):
|
|
new_thread = await _create_thread_litellm(sync_mode, provider, assistant_client)
|
|
data = _request_data(provider, assistant_client, thread_id=new_thread.id)
|
|
|
|
if sync_mode:
|
|
received_thread = get_thread(**data)
|
|
else:
|
|
received_thread = await litellm.aget_thread(**data)
|
|
|
|
assert isinstance(received_thread, Thread)
|
|
|
|
|
|
@pytest.mark.usefixtures("_vcr_outcome_gate", "isolate_litellm_state", "setup_and_teardown")
|
|
@pytest.mark.parametrize("provider", ["openai", "azure"])
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_add_message_litellm(sync_mode, provider, assistant_client):
|
|
new_thread = await _create_thread_litellm(sync_mode, provider, assistant_client)
|
|
message: MessageData = {"role": "user", "content": "Hey, how's it going?"} # type: ignore
|
|
data = _request_data(provider, assistant_client, thread_id=new_thread.id, **message)
|
|
|
|
if sync_mode:
|
|
added_message = litellm.add_message(**data)
|
|
else:
|
|
added_message = await litellm.a_add_message(**data)
|
|
|
|
assert isinstance(added_message, Message)
|
|
|
|
|
|
@pytest.mark.usefixtures("_vcr_outcome_gate", "isolate_litellm_state", "setup_and_teardown")
|
|
@pytest.mark.parametrize("provider", ["azure", "openai"])
|
|
@pytest.mark.parametrize("sync_mode", [True, False])
|
|
@pytest.mark.parametrize("is_streaming", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_aarun_thread_litellm(sync_mode, provider, is_streaming, assistant_client):
|
|
get_assistants_data = _request_data(provider, assistant_client)
|
|
if sync_mode:
|
|
assistants = litellm.get_assistants(**get_assistants_data)
|
|
else:
|
|
assistants = await litellm.aget_assistants(**get_assistants_data)
|
|
|
|
assistant_id = assistants.data[0].id
|
|
new_thread = await _create_thread_litellm(sync_mode, provider, assistant_client)
|
|
message: MessageData = {"role": "user", "content": "Hey, how's it going?"} # type: ignore
|
|
thread_data = _request_data(provider, assistant_client, thread_id=new_thread.id)
|
|
message_data = _request_data(provider, assistant_client, thread_id=new_thread.id, **message)
|
|
|
|
if sync_mode:
|
|
added_message = litellm.add_message(**message_data)
|
|
assert isinstance(added_message, Message)
|
|
|
|
if is_streaming:
|
|
run = litellm.run_thread_stream(assistant_id=assistant_id, **thread_data)
|
|
with run as run:
|
|
assert isinstance(run, AssistantEventHandler)
|
|
run.until_done()
|
|
else:
|
|
run = litellm.run_thread(assistant_id=assistant_id, stream=is_streaming, **thread_data)
|
|
assert run.status == "completed"
|
|
messages = litellm.get_messages(**thread_data)
|
|
assert isinstance(messages.data[0], Message)
|
|
else:
|
|
added_message = await litellm.a_add_message(**message_data)
|
|
assert isinstance(added_message, Message)
|
|
|
|
if is_streaming:
|
|
run = litellm.arun_thread_stream(assistant_id=assistant_id, **thread_data)
|
|
async with run as run:
|
|
assert isinstance(run, AsyncAssistantEventHandler)
|
|
await run.until_done()
|
|
else:
|
|
run = await litellm.arun_thread(
|
|
custom_llm_provider=provider,
|
|
thread_id=new_thread.id,
|
|
assistant_id=assistant_id,
|
|
client=assistant_client,
|
|
)
|
|
assert run.status == "completed"
|
|
messages = await litellm.aget_messages(**thread_data)
|
|
assert isinstance(messages.data[0], Message)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_prediction_param_mock():
|
|
"""
|
|
Tests that prediction parameter is correctly passed to the API
|
|
"""
|
|
litellm.set_verbose = True
|
|
|
|
code = """
|
|
/// <summary>
|
|
/// Represents a user with a first name, last name, and username.
|
|
/// </summary>
|
|
public class User
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the user's first name.
|
|
/// </summary>
|
|
public string FirstName { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the user's last name.
|
|
/// </summary>
|
|
public string LastName { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the user's username.
|
|
/// </summary>
|
|
public string Username { get; set; }
|
|
}
|
|
"""
|
|
from openai import AsyncOpenAI
|
|
|
|
client = AsyncOpenAI(api_key="fake-api-key")
|
|
|
|
with patch.object(
|
|
client, "post"
|
|
) as mock_client:
|
|
try:
|
|
await litellm.acompletion(
|
|
model="gpt-4o-mini",
|
|
messages=[
|
|
{
|
|
"role": "user",
|
|
"content": "Replace the Username property with an Email property. Respond only with code, and with no markdown formatting.",
|
|
},
|
|
{"role": "user", "content": code},
|
|
],
|
|
prediction={"type": "content", "content": code},
|
|
client=client,
|
|
)
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
|
|
mock_client.assert_called_once()
|
|
request_body = mock_client.call_args.kwargs["body"]
|
|
|
|
# Verify the request contains the prediction parameter
|
|
assert "prediction" in request_body
|
|
# verify prediction is correctly sent to the API
|
|
assert request_body["prediction"] == {"type": "content", "content": code}
|
|
|
|
|
|
@patch("litellm.main.openai_chat_completions._get_openai_client")
|
|
def test_openai_max_retries_0(mock_get_openai_client):
|
|
import litellm
|
|
|
|
raw_response: Final = MagicMock(spec=LegacyAPIResponse)
|
|
raw_response.headers = {}
|
|
raw_response.parse.return_value = ModelResponse(choices=[{"message": {"role": "assistant", "content": "Hello"}}])
|
|
client_stub: Final = MagicMock()
|
|
client_stub.post.return_value = raw_response
|
|
mock_get_openai_client.return_value = client_stub
|
|
litellm.set_verbose = True
|
|
response = litellm.completion(
|
|
model="gpt-4o-mini",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
max_retries=0,
|
|
api_key="fake-key",
|
|
)
|
|
|
|
mock_get_openai_client.assert_called_once()
|
|
assert mock_get_openai_client.call_args.kwargs["max_retries"] == 0
|
|
assert response.choices[0].message.content == "Hello"
|
|
|
|
|
|
@patch("litellm.main.openai_chat_completions._get_openai_client")
|
|
def test_openai_image_generation_forwards_organization(mock_get_openai_client):
|
|
"""Ensure organization flows to OpenAI client for image generation."""
|
|
|
|
class _DummyRawImages:
|
|
def generate(self, **kwargs): # type: ignore
|
|
class _Resp:
|
|
def model_dump(self_inner): # minimal OpenAI ImagesResponse shape
|
|
return {
|
|
"created": 123,
|
|
"data": [{"url": "http://example.com/image.png"}],
|
|
"usage": {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"total_tokens": 0,
|
|
},
|
|
}
|
|
|
|
class _RawResp:
|
|
headers = {}
|
|
|
|
def parse(self_inner):
|
|
return _Resp()
|
|
|
|
return _RawResp()
|
|
|
|
class _DummyImages:
|
|
with_raw_response = _DummyRawImages()
|
|
|
|
class _DummyClient:
|
|
def __init__(self):
|
|
self.api_key = "sk-test"
|
|
|
|
class _BaseURL:
|
|
_uri_reference = "https://api.openai.com/v1"
|
|
|
|
self._base_url = _BaseURL()
|
|
self.images = _DummyImages()
|
|
|
|
mock_get_openai_client.return_value = _DummyClient()
|
|
|
|
org = "org_test_123"
|
|
resp = litellm.image_generation(
|
|
model="gpt-image-1",
|
|
prompt="A cute baby sea otter",
|
|
organization=org,
|
|
)
|
|
|
|
# Assert organization forwarded into OpenAI client factory
|
|
assert mock_get_openai_client.call_args.kwargs.get("organization") == org
|
|
|
|
# Basic sanity on response shape
|
|
assert hasattr(resp, "data") and len(resp.data) == 1
|
|
|
|
|
|
def test_openai_chat_completion_streaming_handler_reasoning_content():
|
|
from litellm.llms.openai.chat.gpt_transformation import (
|
|
OpenAIChatCompletionStreamingHandler,
|
|
)
|
|
from unittest.mock import MagicMock
|
|
|
|
streaming_handler = OpenAIChatCompletionStreamingHandler(
|
|
streaming_response=MagicMock(),
|
|
sync_stream=True,
|
|
)
|
|
response = streaming_handler.chunk_parser(
|
|
chunk={
|
|
"id": "e89b6501-8ac2-464c-9550-7cd3daf94350",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1741037890,
|
|
"model": "deepseek-reasoner",
|
|
"system_fingerprint": "fp_5417b77867_prod0225",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"delta": {"content": None, "reasoning_content": "."},
|
|
"logprobs": None,
|
|
"finish_reason": None,
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response.choices[0].delta.reasoning_content == "."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_safety_identifier_parameter():
|
|
"""Test that safety_identifier parameter is correctly passed to the OpenAI API."""
|
|
from openai import AsyncOpenAI
|
|
|
|
litellm.set_verbose = True
|
|
client = AsyncOpenAI(api_key="fake-api-key")
|
|
|
|
with patch.object(
|
|
client, "post"
|
|
) as mock_client:
|
|
try:
|
|
await litellm.acompletion(
|
|
model="openai/gpt-4o",
|
|
messages=[{"role": "user", "content": "Hello, how are you?"}],
|
|
safety_identifier="user_code_123456",
|
|
client=client,
|
|
)
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
|
|
mock_client.assert_called_once()
|
|
request_body = mock_client.call_args.kwargs["body"]
|
|
|
|
# Verify the request contains the safety_identifier parameter
|
|
assert "safety_identifier" in request_body
|
|
# Verify safety_identifier is correctly sent to the API
|
|
assert request_body["safety_identifier"] == "user_code_123456"
|
|
|
|
|
|
def test_openai_safety_identifier_parameter_sync():
|
|
"""Test that safety_identifier parameter is correctly passed to the OpenAI API."""
|
|
from openai import OpenAI
|
|
|
|
litellm.set_verbose = True
|
|
client = OpenAI(api_key="fake-api-key")
|
|
|
|
with patch.object(
|
|
client, "post"
|
|
) as mock_client:
|
|
try:
|
|
litellm.completion(
|
|
model="openai/gpt-4o",
|
|
messages=[{"role": "user", "content": "Hello, how are you?"}],
|
|
safety_identifier="user_code_123456",
|
|
client=client,
|
|
)
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
|
|
mock_client.assert_called_once()
|
|
request_body = mock_client.call_args.kwargs["body"]
|
|
|
|
# Verify the request contains the safety_identifier parameter
|
|
assert "safety_identifier" in request_body
|
|
# Verify safety_identifier is correctly sent to the API
|
|
assert request_body["safety_identifier"] == "user_code_123456"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_service_tier_parameter():
|
|
"""Test that service_tier parameter is correctly passed to the OpenAI API."""
|
|
from openai import AsyncOpenAI
|
|
|
|
litellm.set_verbose = True
|
|
client = AsyncOpenAI(api_key="fake-api-key")
|
|
|
|
with patch.object(
|
|
client, "post"
|
|
) as mock_client:
|
|
try:
|
|
await litellm.acompletion(
|
|
model="openai/gpt-4o",
|
|
messages=[{"role": "user", "content": "Hello, how are you?"}],
|
|
service_tier="priority",
|
|
client=client,
|
|
)
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
|
|
mock_client.assert_called_once()
|
|
request_body = mock_client.call_args.kwargs["body"]
|
|
|
|
# Verify the request contains the service_tier parameter
|
|
assert "service_tier" in request_body, "service_tier should be in request body"
|
|
# Verify service_tier is correctly sent to the API
|
|
assert (
|
|
request_body["service_tier"] == "priority"
|
|
), "service_tier should be 'priority'"
|
|
|
|
|
|
def test_openai_service_tier_parameter_sync():
|
|
"""Test that service_tier parameter is correctly passed to the OpenAI API."""
|
|
from openai import OpenAI
|
|
|
|
litellm.set_verbose = True
|
|
client = OpenAI(api_key="fake-api-key")
|
|
|
|
with patch.object(
|
|
client, "post"
|
|
) as mock_client:
|
|
try:
|
|
litellm.completion(
|
|
model="openai/gpt-4o",
|
|
messages=[{"role": "user", "content": "Hello, how are you?"}],
|
|
service_tier="priority",
|
|
client=client,
|
|
)
|
|
except Exception as e:
|
|
print(f"Error: {e}")
|
|
|
|
mock_client.assert_called_once()
|
|
request_body = mock_client.call_args.kwargs["body"]
|
|
|
|
# Verify the request contains the service_tier parameter
|
|
assert "service_tier" in request_body, "service_tier should be in request body"
|
|
# Verify service_tier is correctly sent to the API
|
|
assert (
|
|
request_body["service_tier"] == "priority"
|
|
), "service_tier should be 'priority'"
|
|
|
|
|
|
def test_responses_gpt54_with_xhigh_reasoning():
|
|
"""
|
|
Ensure chat->responses bridge sends the correct request payload for
|
|
openai/responses/gpt-5.4 with reasoning_effort="xhigh".
|
|
"""
|
|
with patch("litellm.responses") as mock_responses:
|
|
mock_responses.side_effect = RuntimeError("stop_after_request_build")
|
|
|
|
with pytest.raises(litellm.APIConnectionError):
|
|
litellm.completion(
|
|
model="openai/responses/gpt-5.4",
|
|
messages=[{"role": "user", "content": "What is 2+2?"}],
|
|
reasoning_effort="xhigh",
|
|
max_tokens=100,
|
|
)
|
|
|
|
mock_responses.assert_called_once()
|
|
request_body = mock_responses.call_args.kwargs
|
|
|
|
assert request_body["model"] == "openai/gpt-5.4"
|
|
|
|
assert request_body["reasoning"] == {"effort": "xhigh"}
|
|
|
|
|
|
_CHAT_RESPONSE_JSON: Final = {
|
|
"id": "chatcmpl-wire",
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "gpt-5.4",
|
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
}
|
|
_WIRE_REQUEST: Final = {
|
|
"model": "gpt-5.4",
|
|
"messages": [
|
|
{"role": "system", "content": "sys"},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "what is in the image"},
|
|
{"type": "image_url", "image_url": {"url": "https://example.com/a.png", "detail": "low"}},
|
|
],
|
|
},
|
|
],
|
|
"tools": [{"type": "function", "function": {"name": "f", "parameters": {"type": "object", "properties": {}}}}],
|
|
"tool_choice": "auto",
|
|
"response_format": {"type": "json_schema", "json_schema": {"name": "r", "schema": {"type": "object"}}},
|
|
"max_completion_tokens": 7,
|
|
"reasoning_effort": "none",
|
|
"n": 2,
|
|
"seed": 3,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("is_async", (False, True))
|
|
@respx.mock
|
|
async def test_chat_completion_sends_the_caller_params_extra_body_and_extra_headers_unchanged(
|
|
is_async: bool, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
route: Final = respx.post("https://api.openai.com/v1/chat/completions").mock(
|
|
return_value=httpx.Response(200, json=_CHAT_RESPONSE_JSON)
|
|
)
|
|
kwargs: Final = {
|
|
**_WIRE_REQUEST,
|
|
"model": "openai/gpt-5.4",
|
|
"api_base": "https://api.openai.com/v1",
|
|
"api_key": "sk-test",
|
|
"num_retries": 0,
|
|
"extra_body": {"vendor_flag": True},
|
|
"extra_headers": {"x-caller": "unit"},
|
|
}
|
|
|
|
if is_async:
|
|
await litellm.acompletion(**kwargs)
|
|
else:
|
|
litellm.completion(**kwargs)
|
|
|
|
sent: Final = route.calls.last.request
|
|
assert json.loads(sent.content) == {**_WIRE_REQUEST, "vendor_flag": True}
|
|
assert sent.headers["x-caller"] == "unit"
|
|
assert sent.headers["authorization"] == "Bearer sk-test"
|
|
|
|
|
|
def _developer_message_after_a_user_turn() -> list[dict[str, str]]:
|
|
return [
|
|
{"role": "system", "content": "You are terse."},
|
|
{"role": "user", "content": "Hi there"},
|
|
{"role": "assistant", "content": "Hello!"},
|
|
{"role": "developer", "content": "Answer with exactly one word."},
|
|
{"role": "user", "content": "What is the capital of France?"},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("custom_llm_provider", "api_base", "declared", "expected_roles"),
|
|
[
|
|
("openai", "https://api.openai.com/v1", False, ["system", "user", "assistant", "system", "user"]),
|
|
("azure_ai", "https://example.services.ai.azure.com/models", None, ["system", "user", "assistant", "system", "user"]),
|
|
("azure_ai", "https://example.services.ai.azure.com/models", False, ["system", "user", "assistant", "user"]),
|
|
],
|
|
)
|
|
def test_openai_config_hoists_a_later_developer_message_only_for_a_model_off_openai_that_rejects_it(
|
|
declare_mid_conversation_system: Callable[[str, bool], None],
|
|
custom_llm_provider: str,
|
|
api_base: str,
|
|
declared: bool | None,
|
|
expected_roles: list[str],
|
|
) -> None:
|
|
if declared is not None:
|
|
declare_mid_conversation_system(f"{custom_llm_provider}/system-first-model", declared)
|
|
messages: Final = OpenAIConfig().translate_developer_role_to_system_role(
|
|
messages=_developer_message_after_a_user_turn(),
|
|
model="system-first-model",
|
|
custom_llm_provider=custom_llm_provider,
|
|
api_base=api_base,
|
|
model_info=None,
|
|
)
|
|
assert [message["role"] for message in messages] == expected_roles
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_batch_list_given_an_async_client_returns_a_page_the_caller_can_await(
|
|
http_backend: ModuleType,
|
|
):
|
|
def respond(request: _Request) -> httpx.Response | SDKResponse:
|
|
assert str(request.url).startswith("http://vllm-router.test/v1/batches")
|
|
return http_backend.Response(
|
|
200,
|
|
json={
|
|
"object": "list",
|
|
"data": [
|
|
{
|
|
"id": "batch_5f8dac",
|
|
"status": "pending",
|
|
"input_file_id": "file-b73f4c",
|
|
"created_at": 1793239207,
|
|
"endpoint": "/v1/chat/completions",
|
|
"completion_window": "24h",
|
|
}
|
|
],
|
|
"first_id": "batch_5f8dac",
|
|
"last_id": "batch_5f8dac",
|
|
"has_more": False,
|
|
},
|
|
)
|
|
|
|
async with http_backend.AsyncClient(transport=http_backend.MockTransport(respond)) as http_client:
|
|
page: Final = OpenAIBatchesAPI().list_batches(
|
|
_is_async=False,
|
|
api_key="sk-test",
|
|
api_base="http://vllm-router.test/v1",
|
|
timeout=5.0,
|
|
max_retries=0,
|
|
organization=None,
|
|
client=AsyncOpenAI(api_key="sk-test", base_url="http://vllm-router.test/v1", http_client=http_client),
|
|
)
|
|
|
|
assert [(batch.id, batch.status) for batch in (await page).data] == [("batch_5f8dac", "pending")]
|