mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* test: move routing, caching and callback tests out of local_testing Moves the 37 routing, caching and callback files in tests/local_testing into mirrored tests/unit paths and tests/integration sdk and security groups per the migration manifest, and deletes the 8 rows it marks for deletion * test: cover least-busy routing and tighten migrated router, cache and pass-through tests Add router-level least-busy tests for the three legacy rows that had no destination, move completion fallback and model alias tests to their mirror files, move router batch tests next to the router, and strengthen tests whose mutants survived (specific deployment, batch pair grouping, custom cache key, router cache ttl). Add a unit test for configured pass-through Authorization forwarding. * test: tighten migrated callback, proxy config and cache ttl tests Assert the full StandardLoggingPayload key set and the wire user on callback tests, add an async embedding failure callback test, drive the prometheus redis failure through a real RedisCache, use the real salt key in proxy config tests, pin the redis ttl value and rename fallback test helpers * test: fix semantic cache assertions and order-dependent router and callback tests Count semantic cache embeddings on the route, drain async cache writes before the second lookup, echo the requested model from the callback provider mock so response_cost does not depend on model_cost entries other tests register, and move the two-key authorization assertion to the auth failure test it belongs to * test: cover model_list fan-out and the usage-based routing tpm limit lost in the move * test: wait for the streamed cache entry in redis and drop deleted files from the hosted mock guard * test: restore sync callback, audio redaction and langfuse export coverage, keep redis-compat filter on the moved file * ci: run the remaining router-keyword legacy tests on one node * test: refuse the redis write in-process instead of resolving an invalid host --------- Co-authored-by: yuneng <yuneng@berri.ai>
795 lines
36 KiB
Python
795 lines
36 KiB
Python
import asyncio
|
|
import base64
|
|
import json
|
|
import threading
|
|
from collections.abc import AsyncIterator, Iterator
|
|
from typing import Final
|
|
from unittest.mock import AsyncMock, Mock
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
import respx
|
|
|
|
import litellm
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
|
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
|
from litellm.types.utils import StandardLoggingPayload
|
|
|
|
|
|
def _provider_response(request: httpx.Request) -> httpx.Response:
|
|
path: Final = request.url.path
|
|
if request.headers.get("authorization") == "Bearer bad-key":
|
|
return httpx.Response(401, json={"error": {"message": "bad key"}})
|
|
body: Final = json.loads(request.content or b"{}")
|
|
if body.get("model") == "gpt-4o-audio-preview" and body.get("stream"):
|
|
audio_chunks: Final = (
|
|
{
|
|
"id": "chatcmpl_audio",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1700000000,
|
|
"model": "gpt-4o-audio-preview",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"delta": {
|
|
"audio": {
|
|
"id": "audio-response",
|
|
"data": "Zm9v",
|
|
"transcript": "hello ",
|
|
}
|
|
},
|
|
"finish_reason": None,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"id": "chatcmpl_audio",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1700000000,
|
|
"model": "gpt-4o-audio-preview",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"delta": {
|
|
"audio": {
|
|
"data": "YmFy",
|
|
"transcript": "world",
|
|
"expires_at": 1700003600,
|
|
}
|
|
},
|
|
"finish_reason": None,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"id": "chatcmpl_audio",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1700000000,
|
|
"model": "gpt-4o-audio-preview",
|
|
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5},
|
|
},
|
|
)
|
|
stream_body: Final = b"".join(
|
|
f"data: {json.dumps(chunk)}\n\n".encode() for chunk in audio_chunks
|
|
)
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=stream_body + b"data: [DONE]\n\n",
|
|
)
|
|
if body.get("model") == "gpt-4o-audio-preview":
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "chatcmpl_audio",
|
|
"object": "chat.completion",
|
|
"created": 1700000000,
|
|
"model": "gpt-4o-audio-preview",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": None,
|
|
"audio": {
|
|
"id": "audio-response",
|
|
"data": base64.b64encode(b"foobar").decode("ascii"),
|
|
"transcript": "hello world",
|
|
"expires_at": 1700003600,
|
|
},
|
|
},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5},
|
|
},
|
|
)
|
|
if "filtered request" in json.dumps(body):
|
|
return httpx.Response(
|
|
400,
|
|
json={
|
|
"error": {
|
|
"code": "ResponsibleAIPolicyViolation",
|
|
"message": "The response was filtered due to the prompt triggering Azure OpenAI's content management policy",
|
|
"type": "invalid_request_error",
|
|
"innererror": {
|
|
"code": "ResponsibleAIPolicyViolation",
|
|
"content_filter_result": {
|
|
"violence": {"filtered": True, "severity": "high"},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
)
|
|
if "generativelanguage.googleapis.com" in str(request.url):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"candidates": [
|
|
{"content": {"role": "model", "parts": [{"text": "Hello"}]}, "finishReason": "STOP"}
|
|
],
|
|
"usageMetadata": {"promptTokenCount": 2, "candidatesTokenCount": 1, "totalTokenCount": 3},
|
|
},
|
|
)
|
|
if "invoke" in path:
|
|
return httpx.Response(
|
|
200,
|
|
json={"embeddings": [[0.1, 0.2, 0.3]], "inputTextTokenCount": 2},
|
|
)
|
|
if "embeddings" in path:
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"object": "list",
|
|
"data": [{"object": "embedding", "index": 0, "embedding": [0.1, 0.2, 0.3]}],
|
|
"model": body.get("model", "embedding-model"),
|
|
"usage": {"prompt_tokens": 2, "total_tokens": 2},
|
|
},
|
|
)
|
|
if "images/generations" in path:
|
|
return httpx.Response(200, json={"created": 1700000000, "data": [{"url": "https://images.test/result"}]})
|
|
if path.endswith("/messages") and body.get("stream"):
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=(
|
|
b'event: message_start\n'
|
|
b'data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"claude-test","usage":{"input_tokens":2,"output_tokens":0}}}\n\n'
|
|
b'event: content_block_delta\n'
|
|
b'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}\n\n'
|
|
b'event: message_delta\n'
|
|
b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n'
|
|
b'event: message_stop\n'
|
|
b'data: {"type":"message_stop"}\n\n'
|
|
),
|
|
)
|
|
if path.endswith("/completions") and not path.endswith("/chat/completions") and body.get("stream"):
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=(
|
|
b'data: {"id":"cmpl_test","object":"text_completion","created":1700000000,"model":"gpt-3.5-turbo","choices":[{"text":"Hello","index":0,"finish_reason":null}]}\n\n'
|
|
b'data: {"id":"cmpl_test","object":"text_completion","created":1700000000,"model":"gpt-3.5-turbo","choices":[{"text":"","index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}\n\n'
|
|
b"data: [DONE]\n\n"
|
|
),
|
|
)
|
|
if body.get("stream"):
|
|
return httpx.Response(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
content=(
|
|
b'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}\n\n'
|
|
b'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":1700000000,"model":"test-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}\n\n'
|
|
b"data: [DONE]\n\n"
|
|
),
|
|
)
|
|
if path.endswith("/completions") and not path.endswith("/chat/completions"):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "cmpl_test",
|
|
"object": "text_completion",
|
|
"model": "text-model",
|
|
"choices": [{"text": "Hello", "index": 0, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3},
|
|
},
|
|
)
|
|
return httpx.Response(
|
|
200,
|
|
headers={"llm_provider-x-request-id": "respx-request-id"},
|
|
json={
|
|
"id": "chatcmpl_test",
|
|
"object": "chat.completion",
|
|
"created": 1700000000,
|
|
"model": body.get("model", "test-model"),
|
|
"choices": [
|
|
{"index": 0, "message": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop"}
|
|
],
|
|
"usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3},
|
|
},
|
|
)
|
|
|
|
|
|
class _CallbackCapture(CustomLogger):
|
|
def __init__(self) -> None:
|
|
self.call_id: Final = str(uuid4())
|
|
self.sync_success: Final = Mock()
|
|
self.sync_logged: Final = threading.Semaphore(0)
|
|
self.async_success: Final = AsyncMock()
|
|
self.sync_failure: Final = Mock()
|
|
self.async_failure: Final = AsyncMock()
|
|
|
|
def log_success_event(self, kwargs, response_obj, start_time, end_time) -> None:
|
|
self.sync_success(kwargs=kwargs, response_obj=response_obj)
|
|
self.sync_logged.release()
|
|
|
|
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time) -> None:
|
|
await self.async_success(kwargs=kwargs, response_obj=response_obj)
|
|
|
|
def log_failure_event(self, kwargs, response_obj, start_time, end_time) -> None:
|
|
self.sync_failure(kwargs=kwargs, response_obj=response_obj)
|
|
|
|
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time) -> None:
|
|
await self.async_failure(kwargs=kwargs, response_obj=response_obj)
|
|
|
|
|
|
@pytest.fixture
|
|
def provider_router(monkeypatch: pytest.MonkeyPatch) -> Iterator[respx.MockRouter]:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
|
monkeypatch.setenv("AZURE_API_KEY", "test-key")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
|
with respx.mock(assert_all_called=False, assert_all_mocked=True) as router:
|
|
router.route(url__regex=r"https?://.*").mock(side_effect=_provider_response)
|
|
yield router
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="function")
|
|
async def callback_capture(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[_CallbackCapture]:
|
|
await _drain_logging_worker()
|
|
capture: Final = _CallbackCapture()
|
|
monkeypatch.setattr(litellm, "callbacks", [capture])
|
|
monkeypatch.setattr(litellm, "success_callback", [capture])
|
|
monkeypatch.setattr(litellm, "_async_success_callback", [capture])
|
|
monkeypatch.setattr(litellm, "failure_callback", [capture])
|
|
monkeypatch.setattr(litellm, "_async_failure_callback", [capture])
|
|
try:
|
|
yield capture
|
|
finally:
|
|
await _drain_logging_worker()
|
|
|
|
|
|
async def _drain_logging_worker() -> None:
|
|
for _ in range(10):
|
|
await asyncio.sleep(0)
|
|
await GLOBAL_LOGGING_WORKER.flush()
|
|
|
|
|
|
async def _assert_success_payload(
|
|
capture: _CallbackCapture,
|
|
model: str,
|
|
messages: list[dict[str, str]] | None,
|
|
request_marker: str | None = None,
|
|
call_id: str | None = None,
|
|
) -> None:
|
|
await _drain_logging_worker()
|
|
expected_model: Final = model.rsplit("/", maxsplit=1)[-1]
|
|
calls: Final = tuple(
|
|
call
|
|
for call in (*capture.async_success.call_args_list, *capture.sync_success.call_args_list)
|
|
if call.kwargs["kwargs"].get("model") == expected_model
|
|
and (messages is None or call.kwargs["kwargs"].get("messages") == messages)
|
|
and (
|
|
request_marker is None
|
|
or request_marker
|
|
in json.dumps(
|
|
(
|
|
call.kwargs["kwargs"].get("input"),
|
|
call.kwargs["kwargs"].get("prompt"),
|
|
call.kwargs["kwargs"].get("messages"),
|
|
call.kwargs["kwargs"].get("metadata"),
|
|
),
|
|
default=str,
|
|
)
|
|
)
|
|
and (call_id is None or call.kwargs["kwargs"].get("litellm_call_id") == call_id)
|
|
)
|
|
assert calls, (
|
|
f"no callback event matched model={expected_model!r}, messages={messages!r}; "
|
|
f"async models={tuple(item.kwargs['kwargs'].get('model') for item in capture.async_success.call_args_list)!r}, "
|
|
f"sync models={tuple(item.kwargs['kwargs'].get('model') for item in capture.sync_success.call_args_list)!r}"
|
|
)
|
|
call: Final = calls[-1]
|
|
event: Final = call.kwargs["kwargs"]
|
|
assert event["model"] == expected_model
|
|
standard: Final = event["standard_logging_object"]
|
|
expected_standard_model: Final = model if model.startswith("bedrock/") else model.rsplit("/", maxsplit=1)[-1]
|
|
assert standard["model"] == expected_standard_model
|
|
if messages is not None:
|
|
assert event["messages"] == messages
|
|
assert standard["messages"] == messages
|
|
assert standard["response"]["choices"][0]["message"]["content"] == "Hello"
|
|
assert standard["prompt_tokens"] == 2
|
|
assert standard["completion_tokens"] == 1
|
|
assert standard["total_tokens"] == 3
|
|
|
|
|
|
def test_amazing_sync_embedding(provider_router: respx.MockRouter, callback_capture: _CallbackCapture) -> None:
|
|
response: Final = litellm.embedding(
|
|
model="text-embedding-3-small",
|
|
input=["sync embedding marker"],
|
|
metadata={"requester_metadata": {"marker": "embedding"}},
|
|
)
|
|
assert callback_capture.sync_logged.acquire(timeout=5)
|
|
assert response.data[0]["embedding"] == [0.1, 0.2, 0.3]
|
|
event: Final = callback_capture.sync_success.call_args.kwargs["kwargs"]
|
|
assert event["model"] == "text-embedding-3-small"
|
|
assert event["input"] == ["sync embedding marker"]
|
|
assert event["standard_logging_object"]["response"]["data"][0]["embedding"] == [0.1, 0.2, 0.3]
|
|
assert event["standard_logging_object"]["metadata"]["requester_metadata"] == {"marker": "embedding"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_embedding_openai(provider_router: respx.MockRouter, callback_capture: _CallbackCapture) -> None:
|
|
response: Final = await litellm.aembedding(model="text-embedding-3-small", input=["openai embedding marker"])
|
|
await _assert_success_payload(callback_capture, "text-embedding-3-small", None, "openai embedding marker")
|
|
assert response.data[0]["embedding"] == [0.1, 0.2, 0.3]
|
|
event: Final = callback_capture.async_success.call_args.kwargs["kwargs"]
|
|
assert event["input"] == ["openai embedding marker"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_embedding_azure(provider_router: respx.MockRouter, callback_capture: _CallbackCapture) -> None:
|
|
response: Final = await litellm.aembedding(
|
|
model="azure/text-embedding-ada-002",
|
|
input=["azure embedding marker"],
|
|
api_base="https://provider.test/v1",
|
|
)
|
|
await _assert_success_payload(callback_capture, "azure/text-embedding-ada-002", None, "azure embedding marker")
|
|
assert response.data[0]["embedding"] == [0.1, 0.2, 0.3]
|
|
event: Final = callback_capture.async_success.call_args.kwargs["kwargs"]
|
|
assert event["input"] == ["azure embedding marker"]
|
|
assert event["standard_logging_object"]["response"]["data"][0]["embedding"] == [0.1, 0.2, 0.3]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_embedding_bedrock(provider_router: respx.MockRouter, callback_capture: _CallbackCapture) -> None:
|
|
response: Final = await litellm.aembedding(
|
|
model="bedrock/cohere.embed-english-v3",
|
|
input=["bedrock embedding marker"],
|
|
aws_access_key_id="test",
|
|
aws_secret_access_key="test",
|
|
aws_region_name="us-east-1",
|
|
)
|
|
await _assert_success_payload(callback_capture, "bedrock/cohere.embed-english-v3", None, "bedrock embedding marker")
|
|
assert response.data[0]["embedding"] == [0.1, 0.2, 0.3]
|
|
event: Final = callback_capture.async_success.call_args.kwargs["kwargs"]
|
|
assert event["input"] == ["bedrock embedding marker"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_text_completion_openai_stream(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
response: Final = await litellm.atext_completion(
|
|
model="gpt-3.5-turbo",
|
|
prompt="text completion marker",
|
|
stream=True,
|
|
)
|
|
tuple([chunk async for chunk in response])
|
|
await _assert_success_payload(callback_capture, "gpt-3.5-turbo", None, "text completion marker")
|
|
call: Final = callback_capture.async_success.call_args or callback_capture.sync_success.call_args
|
|
event: Final = call.kwargs["kwargs"]
|
|
assert event["model"] == "gpt-3.5-turbo"
|
|
assert event["standard_logging_object"]["response"]["choices"][0]["message"]["content"] == "Hello"
|
|
assert event["standard_logging_object"]["total_tokens"] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_image_generation_openai(provider_router: respx.MockRouter, callback_capture: _CallbackCapture) -> None:
|
|
response: Final = await litellm.aimage_generation(model="openai/gpt-image-1", prompt="image marker")
|
|
assert response.data[0].url == "https://images.test/result"
|
|
await _assert_success_payload(callback_capture, "openai/gpt-image-1", None, "image marker")
|
|
call: Final = callback_capture.async_success.call_args or callback_capture.sync_success.call_args
|
|
event: Final = call.kwargs["kwargs"]
|
|
assert event["standard_logging_object"]["model"] == "gpt-image-1"
|
|
assert event["standard_logging_object"]["response"]["data"][0]["url"] == "https://images.test/result"
|
|
|
|
|
|
def test_logging_standard_payload_failure_call(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
with pytest.raises(litellm.AuthenticationError):
|
|
litellm.completion(
|
|
model="gpt-4o-mini",
|
|
messages=[{"role": "user", "content": "failure marker"}],
|
|
api_key="bad-key",
|
|
)
|
|
messages: Final = [{"role": "user", "content": "failure marker"}]
|
|
failure_calls: Final = tuple(
|
|
call
|
|
for call in (*callback_capture.sync_failure.call_args_list, *callback_capture.async_failure.call_args_list)
|
|
if call.kwargs["kwargs"].get("model") == "gpt-4o-mini" and call.kwargs["kwargs"].get("messages") == messages
|
|
)
|
|
assert len(failure_calls) == 1
|
|
failure_event: Final = failure_calls[0].kwargs["kwargs"]
|
|
assert failure_event["model"] == "gpt-4o-mini"
|
|
assert failure_event["messages"] == messages
|
|
assert failure_event["standard_logging_object"]["status"] == "failure"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_logging_payload_stream_usage(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
response: Final = await litellm.acompletion(
|
|
model="anthropic/claude-test",
|
|
messages=[{"role": "user", "content": "usage marker"}],
|
|
stream=True,
|
|
)
|
|
chunks: Final = tuple([chunk async for chunk in response])
|
|
await _assert_success_payload(callback_capture, "anthropic/claude-test", [{"role": "user", "content": "usage marker"}])
|
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == "Hello"
|
|
event: Final = callback_capture.async_success.call_args.kwargs["kwargs"]
|
|
assert event["standard_logging_object"]["total_tokens"] == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_chat_openai_stream(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
messages: Final = [{"role": "user", "content": "async stream marker"}]
|
|
response: Final = await litellm.acompletion(model="gpt-4o-mini", messages=messages, stream=True)
|
|
chunks: Final = tuple([chunk async for chunk in response])
|
|
await _assert_success_payload(callback_capture, "gpt-4o-mini", messages)
|
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == "Hello"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_custom_handler_completion(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
messages: Final = [{"role": "user", "content": "completion marker"}]
|
|
await litellm.acompletion(model="gpt-4o-mini", messages=messages)
|
|
await _assert_success_payload(callback_capture, "gpt-4o-mini", messages)
|
|
with pytest.raises(litellm.AuthenticationError):
|
|
await litellm.acompletion(model="gpt-4o-mini", messages=messages, api_key="bad-key")
|
|
await _drain_logging_worker()
|
|
failure_calls: Final = tuple(
|
|
call
|
|
for call in callback_capture.async_failure.call_args_list
|
|
if call.kwargs["kwargs"].get("model") == "gpt-4o-mini" and call.kwargs["kwargs"].get("messages") == messages
|
|
)
|
|
assert len(failure_calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_custom_handler_embedding(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
response: Final = await litellm.aembedding(model="text-embedding-3-small", input=["custom handler embedding marker"])
|
|
await _assert_success_payload(callback_capture, "text-embedding-3-small", None, "custom handler embedding marker")
|
|
assert response.usage.prompt_tokens == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_custom_handler_embedding_optional_param(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
await litellm.aembedding(model="text-embedding-3-small", input=["optional embedding marker"], user="user-123")
|
|
await _assert_success_payload(callback_capture, "text-embedding-3-small", None, "optional embedding marker")
|
|
call: Final = callback_capture.async_success.call_args
|
|
assert call.kwargs["kwargs"]["optional_params"]["user"] == "user-123"
|
|
assert json.loads(provider_router.calls.last.request.content)["user"] == "user-123"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_embedding_failure_reaches_failure_callback(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
with pytest.raises(litellm.AuthenticationError):
|
|
await litellm.aembedding(model="text-embedding-3-small", input=["failed embedding marker"], api_key="bad-key")
|
|
await _drain_logging_worker()
|
|
failure_events: Final = tuple(
|
|
call.kwargs["kwargs"]
|
|
for call in callback_capture.async_failure.call_args_list
|
|
if call.kwargs["kwargs"].get("input") == ["failed embedding marker"]
|
|
)
|
|
assert len(failure_events) == 1
|
|
assert failure_events[0]["model"] == "text-embedding-3-small"
|
|
assert isinstance(failure_events[0]["exception"], litellm.AuthenticationError)
|
|
assert callback_capture.async_success.call_args_list == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_custom_handler_stream(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
response: Final = await litellm.acompletion(
|
|
model="azure/gpt-4o-mini",
|
|
messages=[{"role": "user", "content": "azure stream marker"}],
|
|
stream=True,
|
|
api_base="https://provider.test/v1",
|
|
)
|
|
chunks: Final = tuple([chunk async for chunk in response])
|
|
await _assert_success_payload(
|
|
callback_capture,
|
|
"azure/gpt-4o-mini",
|
|
[{"role": "user", "content": "azure stream marker"}],
|
|
)
|
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == "Hello"
|
|
|
|
|
|
def test_chat_openai_stream(provider_router: respx.MockRouter, callback_capture: _CallbackCapture) -> None:
|
|
messages: Final = [{"role": "user", "content": "sync stream marker"}]
|
|
response: Final = litellm.completion(model="gpt-4o-mini", messages=messages, stream=True)
|
|
chunks: Final = tuple(response)
|
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == "Hello"
|
|
assert callback_capture.sync_logged.acquire(timeout=5)
|
|
event: Final = callback_capture.sync_success.call_args.kwargs["kwargs"]
|
|
assert event["model"] == "gpt-4o-mini"
|
|
assert event["messages"] == messages
|
|
assert event["standard_logging_object"]["response"]["choices"][0]["message"]["content"] == "Hello"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_azure_stream(provider_router: respx.MockRouter, callback_capture: _CallbackCapture) -> None:
|
|
messages: Final = [{"role": "user", "content": "sync azure marker"}]
|
|
response: Final = await litellm.acompletion(
|
|
model="azure/gpt-4o-mini",
|
|
messages=messages,
|
|
stream=True,
|
|
api_base="https://provider.test/v1",
|
|
)
|
|
chunks: Final = tuple([chunk async for chunk in response])
|
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == "Hello"
|
|
await _assert_success_payload(callback_capture, "azure/gpt-4o-mini", messages)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_chat_azure_stream(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
response: Final = await litellm.acompletion(
|
|
model="azure/gpt-4o-mini",
|
|
messages=[{"role": "user", "content": "async azure marker"}],
|
|
stream=True,
|
|
api_base="https://provider.test/v1",
|
|
)
|
|
tuple([chunk async for chunk in response])
|
|
await _assert_success_payload(callback_capture, "azure/gpt-4o-mini", [{"role": "user", "content": "async azure marker"}])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_chat_openai_stream_options(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
response: Final = await litellm.acompletion(
|
|
model="gpt-4o-mini",
|
|
messages=[{"role": "user", "content": "stream usage marker"}],
|
|
stream=True,
|
|
stream_options={"include_usage": True},
|
|
)
|
|
chunks: Final = tuple([chunk async for chunk in response])
|
|
await _assert_success_payload(callback_capture, "gpt-4o-mini", [{"role": "user", "content": "stream usage marker"}])
|
|
assert chunks[-1].usage.total_tokens == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standard_logging_payload(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
messages: Final = [{"role": "user", "content": "standard marker"}]
|
|
response: Final = await litellm.acompletion(
|
|
model="gpt-4o-mini",
|
|
messages=messages,
|
|
metadata={"requester_metadata": {"marker": "standard"}},
|
|
)
|
|
await _assert_success_payload(callback_capture, "gpt-4o-mini", messages)
|
|
event: Final = callback_capture.async_success.call_args.kwargs["kwargs"]
|
|
assert event["model"] == "gpt-4o-mini"
|
|
assert event["messages"] == messages
|
|
assert event["standard_logging_object"]["response"]["choices"][0]["message"]["content"] == "Hello"
|
|
assert event["standard_logging_object"]["metadata"]["requester_metadata"] == {"marker": "standard"}
|
|
assert response.choices[0].message.content == "Hello"
|
|
standard: Final = event["standard_logging_object"]
|
|
assert sorted(StandardLoggingPayload.__required_keys__ - standard.keys()) == []
|
|
assert json.loads(json.dumps(standard))["id"] == standard["id"]
|
|
assert standard["response_cost"] > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_turn_off_message_logging(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
marker: Final = "secret callback message"
|
|
monkeypatch.setattr(litellm, "turn_off_message_logging", True)
|
|
await litellm.acompletion(
|
|
model="gpt-4o-mini",
|
|
messages=[{"role": "user", "content": marker}],
|
|
litellm_call_id=callback_capture.call_id,
|
|
)
|
|
await _assert_success_payload(callback_capture, "gpt-4o-mini", None, call_id=callback_capture.call_id)
|
|
call: Final = callback_capture.async_success.call_args or callback_capture.sync_success.call_args
|
|
event: Final = call.kwargs["kwargs"]
|
|
assert event["standard_logging_object"]["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
|
|
assert event["standard_logging_object"]["response"]["choices"][0]["message"]["content"] == "redacted-by-litellm"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("turn_off_message_logging", [False, True])
|
|
async def test_logging_async_cache_hit_sync_call(
|
|
provider_router: respx.MockRouter,
|
|
callback_capture: _CallbackCapture,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
turn_off_message_logging: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "cache", litellm.Cache(type="local"))
|
|
monkeypatch.setattr(litellm, "turn_off_message_logging", turn_off_message_logging)
|
|
messages: Final = [{"role": "user", "content": "cache marker"}]
|
|
first: Final = await litellm.acompletion(
|
|
model="gpt-4o-mini",
|
|
messages=messages,
|
|
caching=True,
|
|
stream=True,
|
|
litellm_call_id=callback_capture.call_id,
|
|
)
|
|
first_chunks: Final = tuple([chunk async for chunk in first])
|
|
await _drain_logging_worker()
|
|
second: Final = await litellm.acompletion(
|
|
model="gpt-4o-mini",
|
|
messages=messages,
|
|
caching=True,
|
|
stream=True,
|
|
litellm_call_id=callback_capture.call_id,
|
|
)
|
|
second_chunks: Final = tuple([chunk async for chunk in second])
|
|
await _drain_logging_worker()
|
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in first_chunks) == "Hello"
|
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in second_chunks) == "Hello"
|
|
assert provider_router.calls.call_count == 1
|
|
await _assert_success_payload(callback_capture, "gpt-4o-mini", None, call_id=callback_capture.call_id)
|
|
call: Final = callback_capture.async_success.call_args or callback_capture.sync_success.call_args
|
|
event: Final = call.kwargs["kwargs"]
|
|
standard: Final = event["standard_logging_object"]
|
|
assert standard["cache_hit"] is True
|
|
assert standard["response_cost"] == 0
|
|
assert standard["saved_cache_cost"] > 0
|
|
if turn_off_message_logging:
|
|
assert standard["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
|
|
assert standard["response"]["choices"][0]["message"]["content"] == "redacted-by-litellm"
|
|
|
|
|
|
@pytest.mark.parametrize("turn_off_message_logging", [False, True])
|
|
def test_logging_cache_hit_sync_stream_call(
|
|
provider_router: respx.MockRouter,
|
|
callback_capture: _CallbackCapture,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
turn_off_message_logging: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "cache", litellm.Cache(type="local"))
|
|
monkeypatch.setattr(litellm, "turn_off_message_logging", turn_off_message_logging)
|
|
messages: Final = [{"role": "user", "content": "sync cache marker"}]
|
|
first_chunks: Final = tuple(
|
|
litellm.completion(model="gpt-4o-mini", messages=messages, caching=True, stream=True)
|
|
)
|
|
assert callback_capture.sync_logged.acquire(timeout=5)
|
|
second_chunks: Final = tuple(
|
|
litellm.completion(model="gpt-4o-mini", messages=messages, caching=True, stream=True)
|
|
)
|
|
assert callback_capture.sync_logged.acquire(timeout=5)
|
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in first_chunks) == "Hello"
|
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in second_chunks) == "Hello"
|
|
assert provider_router.calls.call_count == 1
|
|
standard: Final = callback_capture.sync_success.call_args.kwargs["kwargs"]["standard_logging_object"]
|
|
assert standard["cache_hit"] is True
|
|
assert standard["response_cost"] == 0
|
|
assert standard["saved_cache_cost"] > 0
|
|
if turn_off_message_logging:
|
|
assert standard["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
|
|
assert standard["response"]["choices"][0]["message"]["content"] == "redacted-by-litellm"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logging_standard_payload_llm_headers(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
messages: Final = [{"role": "user", "content": "headers marker"}]
|
|
await litellm.acompletion(model="gpt-4o-mini", messages=messages)
|
|
await _assert_success_payload(callback_capture, "gpt-4o-mini", messages)
|
|
event: Final = callback_capture.async_success.call_args.kwargs["kwargs"]
|
|
assert event["model"] == "gpt-4o-mini"
|
|
assert event["messages"] == [{"role": "user", "content": "headers marker"}]
|
|
hidden_params: Final = event["standard_logging_object"]["hidden_params"]
|
|
assert hidden_params["additional_headers"]["llm_provider-x-request-id"] == "respx-request-id"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logging_key_masking_gemini(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
hidden_params: Final = StandardLoggingPayloadSetup.get_hidden_params({"api_key": "secret-gemini-key"})
|
|
assert "secret-gemini-key" not in json.dumps(hidden_params)
|
|
messages: Final = [{"role": "user", "content": "key masking marker"}]
|
|
await litellm.acompletion(
|
|
model="gemini/gemini-1.5-flash",
|
|
messages=messages,
|
|
api_key="secret-gemini-key",
|
|
)
|
|
await _assert_success_payload(callback_capture, "gemini/gemini-1.5-flash", messages)
|
|
event: Final = callback_capture.async_success.call_args.kwargs["kwargs"]
|
|
assert "secret-gemini-key" not in json.dumps(event["standard_logging_object"])
|
|
assert event["standard_logging_object"]["model"] == "gemini-1.5-flash"
|
|
|
|
|
|
@pytest.mark.parametrize("turn_off_message_logging", [False, True])
|
|
@pytest.mark.parametrize("stream", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_standard_logging_payload_audio(
|
|
provider_router: respx.MockRouter,
|
|
callback_capture: _CallbackCapture,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
stream: bool,
|
|
turn_off_message_logging: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "turn_off_message_logging", turn_off_message_logging)
|
|
messages: Final = [{"role": "user", "content": "response in 1 word - yes or no"}]
|
|
response: Final = await litellm.acompletion(
|
|
model="openai/gpt-4o-audio-preview",
|
|
modalities=["text", "audio"],
|
|
audio={"voice": "alloy", "format": "pcm16"},
|
|
messages=messages,
|
|
stream=stream,
|
|
litellm_call_id=callback_capture.call_id,
|
|
)
|
|
if stream:
|
|
_ = [chunk async for chunk in response]
|
|
assert json.loads(provider_router.calls[0].request.content)["model"] == "gpt-4o-audio-preview"
|
|
await _assert_success_payload(
|
|
callback_capture, "gpt-4o-audio-preview", None, call_id=callback_capture.call_id
|
|
)
|
|
call: Final = callback_capture.async_success.call_args or callback_capture.sync_success.call_args
|
|
standard: Final = call.kwargs["kwargs"]["standard_logging_object"]
|
|
assert standard["model"] == "gpt-4o-audio-preview"
|
|
message: Final = standard["response"]["choices"][0]["message"]
|
|
if turn_off_message_logging:
|
|
assert standard["messages"] == [{"role": "user", "content": "redacted-by-litellm"}]
|
|
assert message.get("audio") is None
|
|
assert "hello world" not in json.dumps(standard)
|
|
return
|
|
audio: Final = message["audio"]
|
|
assert audio["id"] == "audio-response"
|
|
assert audio["data"] == base64.b64encode(b"foobar").decode("ascii")
|
|
assert audio["transcript"] == "hello world"
|
|
assert audio["expires_at"] == 1700003600
|
|
|
|
|
|
def test_completion_azure_stream_moderation_failure(
|
|
provider_router: respx.MockRouter, callback_capture: _CallbackCapture
|
|
) -> None:
|
|
messages: Final = [
|
|
{"role": "system", "content": "You are a helpful assistant."},
|
|
{"role": "user", "content": "filtered request"},
|
|
]
|
|
with pytest.raises(litellm.ContentPolicyViolationError):
|
|
litellm.completion(
|
|
model="azure/gpt-4o-mini",
|
|
messages=messages,
|
|
stream=True,
|
|
api_base="https://provider.test/v1",
|
|
api_key="test-key",
|
|
)
|
|
failure_calls: Final = tuple(
|
|
call
|
|
for call in (*callback_capture.sync_failure.call_args_list, *callback_capture.async_failure.call_args_list)
|
|
if call.kwargs["kwargs"].get("model") == "gpt-4o-mini" and call.kwargs["kwargs"].get("messages") == messages
|
|
)
|
|
assert len(failure_calls) == 1
|
|
failure_event: Final = failure_calls[0].kwargs["kwargs"]
|
|
assert failure_event["model"] == "gpt-4o-mini"
|
|
assert failure_event["messages"] == messages
|
|
assert failure_event["standard_logging_object"]["status"] == "failure"
|