Files
2026-07-13 13:22:34 +08:00

989 lines
33 KiB
Python

from unittest import mock
import pytest
from mlflow.exceptions import MlflowException
from mlflow.gateway.config import EndpointConfig
from mlflow.gateway.providers.base import PassthroughAction
from mlflow.gateway.providers.litellm import LiteLLMAdapter, LiteLLMProvider
from mlflow.gateway.schemas import chat, embeddings
TEST_MESSAGE = "This is a test"
def chat_config():
return {
"name": "chat",
"endpoint_type": "llm/v1/chat",
"model": {
"provider": "litellm",
"name": "claude-3-5-sonnet-20241022",
"config": {
"litellm_auth_config": {"api_key": "test-key"},
},
},
}
def chat_config_with_api_base():
return {
"name": "chat",
"endpoint_type": "llm/v1/chat",
"model": {
"provider": "litellm",
"name": "custom-model",
"config": {
"litellm_auth_config": {
"api_key": "test-key",
"api_base": "https://custom-api.example.com",
},
},
},
}
def chat_config_with_provider():
return {
"name": "chat",
"endpoint_type": "llm/v1/chat",
"model": {
"provider": "litellm",
"name": "claude-3-5-sonnet-20241022",
"config": {
"litellm_provider": "anthropic",
"litellm_auth_config": {"api_key": "test-key"},
},
},
}
def embeddings_config():
return {
"name": "embeddings",
"endpoint_type": "llm/v1/embeddings",
"model": {
"provider": "litellm",
"name": "text-embedding-3-small",
"config": {
"litellm_auth_config": {"api_key": "test-key"},
},
},
}
def mock_litellm_chat_response():
"""Create a mock LiteLLM chat response object."""
response = mock.MagicMock()
response.id = "litellm-chat-id"
response.object = "chat.completion"
response.created = 1234567890
response.model = "claude-3-5-sonnet-20241022"
choice = mock.MagicMock()
choice.index = 0
choice.message = mock.MagicMock()
choice.message.role = "assistant"
choice.message.content = TEST_MESSAGE
choice.message.tool_calls = None
choice.finish_reason = "stop"
response.choices = [choice]
response.usage = mock.MagicMock()
response.usage.prompt_tokens = 10
response.usage.completion_tokens = 20
response.usage.total_tokens = 30
response.usage.prompt_tokens_details = None
return response
def mock_litellm_embeddings_response():
"""Create a mock LiteLLM embeddings response object."""
response = mock.MagicMock()
response.model = "text-embedding-3-small"
data = mock.MagicMock()
data.__getitem__ = lambda self, key: [0.1, 0.2, 0.3] if key == "embedding" else None
response.data = [data]
response.usage = mock.MagicMock()
response.usage.prompt_tokens = 5
response.usage.total_tokens = 5
return response
def test_litellm_not_installed():
config = chat_config()
with mock.patch("importlib.util.find_spec", return_value=None):
with pytest.raises(MlflowException, match="pip install litellm"):
LiteLLMProvider(EndpointConfig(**config))
@pytest.mark.asyncio
async def test_chat():
config = chat_config()
mock_response = mock_litellm_chat_response()
with mock.patch("litellm.acompletion", return_value=mock_response) as mock_completion:
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {
"messages": [{"role": "user", "content": TEST_MESSAGE}],
"temperature": 0.7,
"max_tokens": 100,
}
response = await provider.chat(chat.RequestPayload(**payload))
assert response.id == "litellm-chat-id"
assert response.object == "chat.completion"
assert response.model == "claude-3-5-sonnet-20241022"
assert len(response.choices) == 1
assert response.choices[0].message.content == TEST_MESSAGE
assert response.usage.prompt_tokens == 10
assert response.usage.completion_tokens == 20
assert response.usage.total_tokens == 30
# Verify litellm was called with correct parameters
mock_completion.assert_called_once()
call_kwargs = mock_completion.call_args[1]
assert call_kwargs["model"] == "claude-3-5-sonnet-20241022"
assert call_kwargs["messages"] == [{"role": "user", "content": TEST_MESSAGE}]
assert call_kwargs["temperature"] == 0.7
assert call_kwargs["max_tokens"] == 100
assert call_kwargs["api_key"] == "test-key"
@pytest.mark.asyncio
async def test_chat_with_api_base():
config = chat_config_with_api_base()
mock_response = mock_litellm_chat_response()
with mock.patch("litellm.acompletion", return_value=mock_response) as mock_completion:
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {"messages": [{"role": "user", "content": TEST_MESSAGE}]}
await provider.chat(chat.RequestPayload(**payload))
# Verify API base is passed
call_kwargs = mock_completion.call_args[1]
assert call_kwargs["api_base"] == "https://custom-api.example.com"
@pytest.mark.asyncio
async def test_chat_with_provider_prefix():
config = chat_config_with_provider()
mock_response = mock_litellm_chat_response()
with mock.patch("litellm.acompletion", return_value=mock_response) as mock_completion:
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {"messages": [{"role": "user", "content": TEST_MESSAGE}]}
await provider.chat(chat.RequestPayload(**payload))
# Verify model name includes provider prefix
call_kwargs = mock_completion.call_args[1]
assert call_kwargs["model"] == "anthropic/claude-3-5-sonnet-20241022"
@pytest.mark.asyncio
async def test_chat_stream():
config = chat_config()
# Create mock streaming chunks
async def mock_stream():
chunk1 = mock.MagicMock()
chunk1.id = "chunk-1"
chunk1.object = "chat.completion.chunk"
chunk1.created = 1234567890
chunk1.model = "claude-3-5-sonnet-20241022"
chunk1.usage = None
choice1 = mock.MagicMock()
choice1.index = 0
choice1.delta = mock.MagicMock(spec=["role", "content"])
choice1.delta.role = "assistant"
choice1.delta.content = "Hello"
choice1.finish_reason = None
chunk1.choices = [choice1]
yield chunk1
chunk2 = mock.MagicMock()
chunk2.id = "chunk-2"
chunk2.object = "chat.completion.chunk"
chunk2.created = 1234567890
chunk2.model = "claude-3-5-sonnet-20241022"
chunk2.usage = None
choice2 = mock.MagicMock()
choice2.index = 0
choice2.delta = mock.MagicMock(spec=["content"])
choice2.delta.content = " world"
choice2.finish_reason = "stop"
chunk2.choices = [choice2]
yield chunk2
# Final usage-only chunk (litellm emits this when include_usage is set).
chunk3 = mock.MagicMock()
chunk3.id = "chunk-3"
chunk3.object = "chat.completion.chunk"
chunk3.created = 1234567890
chunk3.model = "claude-3-5-sonnet-20241022"
chunk3.choices = []
chunk3.usage = mock.MagicMock()
chunk3.usage.prompt_tokens = 10
chunk3.usage.completion_tokens = 20
chunk3.usage.total_tokens = 30
chunk3.usage.prompt_tokens_details = None
yield chunk3
with mock.patch("litellm.acompletion", return_value=mock_stream()) as mock_completion:
provider = LiteLLMProvider(EndpointConfig(**config), enable_tracing=True)
payload = {
"messages": [{"role": "user", "content": "Hello"}],
"stream": True,
}
chunks = [chunk async for chunk in provider.chat_stream(chat.RequestPayload(**payload))]
assert len(chunks) == 3
assert chunks[0].choices[0].delta.content == "Hello"
assert chunks[1].choices[0].delta.content == " world"
assert chunks[1].choices[0].finish_reason == "stop"
# Final chunk forwards token usage instead of content.
assert chunks[2].usage.prompt_tokens == 10
assert chunks[2].usage.completion_tokens == 20
assert chunks[2].usage.total_tokens == 30
# No cache details reported by the provider => field is omitted.
assert chunks[2].usage.prompt_tokens_details is None
# Verify stream parameter was set
call_kwargs = mock_completion.call_args[1]
assert call_kwargs["stream"] is True
assert call_kwargs["stream_options"]["include_usage"] is True
@pytest.mark.asyncio
async def test_chat_stream_forwards_zero_token_usage():
config = chat_config()
async def mock_stream():
chunk = mock.MagicMock()
chunk.id = "chunk-1"
chunk.object = "chat.completion.chunk"
chunk.created = 1234567890
chunk.model = "claude-3-5-sonnet-20241022"
chunk.choices = []
chunk.usage = mock.MagicMock()
chunk.usage.prompt_tokens = 0
chunk.usage.completion_tokens = 0
chunk.usage.total_tokens = 0
chunk.usage.prompt_tokens_details = None
yield chunk
with mock.patch("litellm.acompletion", return_value=mock_stream()):
provider = LiteLLMProvider(EndpointConfig(**config), enable_tracing=True)
payload = {"messages": [{"role": "user", "content": "Hi"}], "stream": True}
chunks = [chunk async for chunk in provider.chat_stream(chat.RequestPayload(**payload))]
# A valid all-zero usage payload must still be forwarded, not dropped.
assert chunks[-1].usage.total_tokens == 0
@pytest.mark.asyncio
async def test_chat_stream_forwards_cached_tokens():
config = chat_config()
async def mock_stream():
chunk = mock.MagicMock()
chunk.id = "chunk-1"
chunk.object = "chat.completion.chunk"
chunk.created = 1234567890
chunk.model = "claude-3-5-sonnet-20241022"
chunk.choices = []
chunk.usage = mock.MagicMock()
chunk.usage.prompt_tokens = 100
chunk.usage.completion_tokens = 10
chunk.usage.total_tokens = 110
chunk.usage.prompt_tokens_details = mock.MagicMock()
chunk.usage.prompt_tokens_details.cached_tokens = 80
yield chunk
with mock.patch("litellm.acompletion", return_value=mock_stream()):
provider = LiteLLMProvider(EndpointConfig(**config), enable_tracing=True)
payload = {"messages": [{"role": "user", "content": "Hi"}], "stream": True}
chunks = [chunk async for chunk in provider.chat_stream(chat.RequestPayload(**payload))]
# Cache-read tokens (a subset of prompt_tokens) are forwarded so clients can price cache hits.
assert chunks[-1].usage.prompt_tokens == 100
assert chunks[-1].usage.prompt_tokens_details.cached_tokens == 80
@pytest.mark.asyncio
async def test_embeddings():
config = embeddings_config()
mock_response = mock_litellm_embeddings_response()
with mock.patch("litellm.aembedding", return_value=mock_response) as mock_embedding:
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {"input": "Hello world"}
response = await provider.embeddings(embeddings.RequestPayload(**payload))
assert response.model == "text-embedding-3-small"
assert len(response.data) == 1
assert response.data[0].embedding == [0.1, 0.2, 0.3]
assert response.usage.prompt_tokens == 5
assert response.usage.total_tokens == 5
# Verify litellm was called with correct parameters
mock_embedding.assert_called_once()
call_kwargs = mock_embedding.call_args[1]
assert call_kwargs["model"] == "text-embedding-3-small"
assert call_kwargs["input"] == "Hello world"
assert call_kwargs["api_key"] == "test-key"
@pytest.mark.asyncio
async def test_embeddings_batch():
config = embeddings_config()
# Create mock response for batch
response = mock.MagicMock()
response.model = "text-embedding-3-small"
data1 = mock.MagicMock()
data1.__getitem__ = lambda self, key: [0.1, 0.2, 0.3] if key == "embedding" else None
data2 = mock.MagicMock()
data2.__getitem__ = lambda self, key: [0.4, 0.5, 0.6] if key == "embedding" else None
response.data = [data1, data2]
response.usage = mock.MagicMock()
response.usage.prompt_tokens = 10
response.usage.total_tokens = 10
with mock.patch("litellm.aembedding", return_value=response):
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {"input": ["Hello", "World"]}
response_payload = await provider.embeddings(embeddings.RequestPayload(**payload))
assert len(response_payload.data) == 2
assert response_payload.data[0].embedding == [0.1, 0.2, 0.3]
assert response_payload.data[1].embedding == [0.4, 0.5, 0.6]
def test_adapter_chat_to_model():
config = EndpointConfig(**chat_config())
payload = {
"messages": [{"role": "user", "content": TEST_MESSAGE}],
"temperature": 0.7,
}
result = LiteLLMAdapter.chat_to_model(payload, config)
assert result["model"] == "claude-3-5-sonnet-20241022"
assert result["messages"] == [{"role": "user", "content": TEST_MESSAGE}]
assert result["temperature"] == 0.7
def test_adapter_embeddings_to_model():
config = EndpointConfig(**embeddings_config())
payload = {"input": TEST_MESSAGE}
result = LiteLLMAdapter.embeddings_to_model(payload, config)
assert result["model"] == "text-embedding-3-small"
assert result["input"] == TEST_MESSAGE
def test_adapter_chat_to_model_with_provider():
config = EndpointConfig(**chat_config_with_provider())
payload = {
"messages": [{"role": "user", "content": TEST_MESSAGE}],
"temperature": 0.7,
}
result = LiteLLMAdapter.chat_to_model(payload, config)
assert result["model"] == "anthropic/claude-3-5-sonnet-20241022"
assert result["messages"] == [{"role": "user", "content": TEST_MESSAGE}]
assert result["temperature"] == 0.7
def test_adapter_model_to_chat():
config = EndpointConfig(**chat_config())
resp = {
"id": "test-id",
"object": "chat.completion",
"created": 1234567890,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": TEST_MESSAGE, "tool_calls": None},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
}
result = LiteLLMAdapter.model_to_chat(resp, config)
assert result.id == "test-id"
assert result.model == "test-model"
assert len(result.choices) == 1
assert result.choices[0].message.content == TEST_MESSAGE
assert result.usage.prompt_tokens == 10
def test_adapter_model_to_embeddings():
config = EndpointConfig(**embeddings_config())
resp = {
"data": [{"embedding": [0.1, 0.2, 0.3], "index": 0}],
"model": "test-model",
"usage": {"prompt_tokens": 5, "total_tokens": 5},
}
result = LiteLLMAdapter.model_to_embeddings(resp, config)
assert result.model == "test-model"
assert len(result.data) == 1
assert result.data[0].embedding == [0.1, 0.2, 0.3]
assert result.data[0].index == 0
assert result.usage.prompt_tokens == 5
# Passthrough tests
def mock_response_with_model_dump():
"""Create a mock response object that supports model_dump()."""
response = mock.MagicMock()
response.model_dump.return_value = {
"id": "test-response-id",
"output": "Test response output",
"model": "test-model",
}
return response
@pytest.mark.asyncio
async def test_passthrough_openai_responses():
config = chat_config()
mock_response = mock_response_with_model_dump()
with mock.patch("litellm.aresponses", return_value=mock_response) as mock_aresponses:
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {"input": "Hello, world!"}
result = await provider.passthrough(
PassthroughAction.OPENAI_RESPONSES,
payload,
headers=None,
)
assert result["id"] == "test-response-id"
assert result["output"] == "Test response output"
mock_aresponses.assert_called_once()
call_kwargs = mock_aresponses.call_args[1]
assert call_kwargs["model"] == "claude-3-5-sonnet-20241022"
assert call_kwargs["input"] == "Hello, world!"
@pytest.mark.asyncio
async def test_passthrough_openai_responses_streaming():
config = chat_config()
async def mock_stream():
for i in range(2):
chunk = mock.MagicMock()
chunk.model_dump.return_value = {"chunk": i, "content": f"part{i}"}
yield chunk
with mock.patch("litellm.aresponses", return_value=mock_stream()):
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {"input": "Hello, world!", "stream": True}
result = await provider.passthrough(
PassthroughAction.OPENAI_RESPONSES,
payload,
headers=None,
)
chunks = [chunk async for chunk in result]
assert len(chunks) == 2
assert b"data:" in chunks[0]
@pytest.mark.asyncio
async def test_passthrough_anthropic_messages():
config = chat_config_with_provider()
mock_response = mock.MagicMock()
mock_response.model_dump.return_value = {
"id": "msg-test-id",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "Hello!"}],
}
with mock.patch(
"litellm.anthropic.messages.acreate", return_value=mock_response
) as mock_acreate:
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 100,
}
result = await provider.passthrough(
PassthroughAction.ANTHROPIC_MESSAGES,
payload,
headers=None,
)
assert result["id"] == "msg-test-id"
assert result["type"] == "message"
mock_acreate.assert_called_once()
call_kwargs = mock_acreate.call_args[1]
assert call_kwargs["model"] == "anthropic/claude-3-5-sonnet-20241022"
assert call_kwargs["max_tokens"] == 100
@pytest.mark.asyncio
async def test_passthrough_anthropic_messages_streaming():
config = chat_config_with_provider()
async def mock_stream():
# LiteLLM returns raw SSE bytes for Anthropic streaming
yield b'event: message_start\ndata: {"type":"message_start"}\n\n'
yield b'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"text":"Hello"}}\n\n' # noqa: E501
yield b'event: message_stop\ndata: {"type":"message_stop"}\n\n'
with mock.patch(
"litellm.anthropic.messages.acreate", return_value=mock_stream()
) as mock_acreate:
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 100,
"stream": True,
}
result = await provider.passthrough(
PassthroughAction.ANTHROPIC_MESSAGES,
payload,
headers=None,
)
chunks = [chunk async for chunk in result]
assert len(chunks) == 3
assert b"message_start" in chunks[0]
assert b"content_block_delta" in chunks[1]
assert b"message_stop" in chunks[2]
mock_acreate.assert_called_once()
call_kwargs = mock_acreate.call_args[1]
assert call_kwargs["stream"] is True
assert call_kwargs["model"] == "anthropic/claude-3-5-sonnet-20241022"
@pytest.mark.asyncio
async def test_passthrough_gemini_generate_content():
config = chat_config()
mock_response = mock.MagicMock()
mock_response.model_dump.return_value = {
"candidates": [{"content": {"parts": [{"text": "Generated content"}]}}],
}
with mock.patch(
"litellm.google_genai.agenerate_content", return_value=mock_response
) as mock_agenerate:
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {
"contents": [{"parts": [{"text": "Generate something"}]}],
}
result = await provider.passthrough(
PassthroughAction.GEMINI_GENERATE_CONTENT,
payload,
headers=None,
)
assert "candidates" in result
mock_agenerate.assert_called_once()
@pytest.mark.asyncio
async def test_passthrough_gemini_stream_generate_content():
config = chat_config()
async def mock_stream():
for i in range(2):
chunk = mock.MagicMock()
chunk.model_dump.return_value = {
"candidates": [{"content": {"parts": [{"text": f"chunk{i}"}]}}]
}
yield chunk
# agenerate_content is called with stream=True for streaming
with mock.patch("litellm.google_genai.agenerate_content", return_value=mock_stream()):
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {
"contents": [{"parts": [{"text": "Generate something"}]}],
}
result = provider._passthrough_gemini_stream_generate_content({
"model": "claude-3-5-sonnet-20241022",
**payload,
})
chunks = [chunk async for chunk in result]
assert len(chunks) == 2
assert chunks[0].model_dump() == {
"candidates": [{"content": {"parts": [{"text": "chunk0"}]}}]
}
@pytest.mark.asyncio
async def test_passthrough_openai_chat():
config = chat_config()
mock_response = mock_response_with_model_dump()
mock_response.model_dump.return_value = {
"id": "chatcmpl-test",
"object": "chat.completion",
"choices": [{"message": {"role": "assistant", "content": "Hello!"}}],
}
with mock.patch("litellm.acompletion", return_value=mock_response) as mock_completion:
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {
"messages": [{"role": "user", "content": "Hello"}],
}
result = await provider.passthrough(
PassthroughAction.OPENAI_CHAT,
payload,
headers=None,
)
assert result["id"] == "chatcmpl-test"
assert result["object"] == "chat.completion"
mock_completion.assert_called_once()
@pytest.mark.asyncio
async def test_passthrough_openai_embeddings():
config = embeddings_config()
mock_response = mock.MagicMock()
mock_response.model_dump.return_value = {
"object": "list",
"data": [{"embedding": [0.1, 0.2, 0.3], "index": 0}],
"model": "text-embedding-3-small",
}
with mock.patch("litellm.aembedding", return_value=mock_response) as mock_embedding:
provider = LiteLLMProvider(EndpointConfig(**config))
payload = {"input": "Hello, world!"}
result = await provider.passthrough(
PassthroughAction.OPENAI_EMBEDDINGS,
payload,
headers=None,
)
assert result["object"] == "list"
assert len(result["data"]) == 1
mock_embedding.assert_called_once()
def test_response_to_dict_with_model_dump():
config = chat_config()
provider = LiteLLMProvider(EndpointConfig(**config))
response = mock.MagicMock()
response.model_dump.return_value = {"key": "value"}
result = provider._response_to_dict(response)
assert result == {"key": "value"}
def test_response_to_dict_with_dict_input():
config = chat_config()
provider = LiteLLMProvider(EndpointConfig(**config))
result = provider._response_to_dict({"key": "value"})
assert result == {"key": "value"}
def test_response_to_dict_with_unknown_type_raises():
config = chat_config()
provider = LiteLLMProvider(EndpointConfig(**config))
with pytest.raises(TypeError, match="Unexpected response type"):
provider._response_to_dict("string value")
# Token extraction tests
def test_litellm_extract_passthrough_token_usage_openai_format():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
result = {
"id": "chatcmpl-123",
"usage": {
"prompt_tokens": 10,
"completion_tokens": 20,
"total_tokens": 30,
},
}
token_usage = provider._extract_passthrough_token_usage(PassthroughAction.OPENAI_CHAT, result)
assert token_usage == {
"input_tokens": 10,
"output_tokens": 20,
"total_tokens": 30,
}
def test_litellm_extract_passthrough_token_usage_anthropic_format():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
result = {
"id": "msg_123",
"usage": {
"input_tokens": 100,
"output_tokens": 50,
},
}
token_usage = provider._extract_passthrough_token_usage(
PassthroughAction.ANTHROPIC_MESSAGES, result
)
assert token_usage == {
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150,
}
def test_litellm_extract_passthrough_token_usage_gemini_format():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
result = {
"candidates": [{"content": {"parts": [{"text": "Hello"}]}}],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 20,
"totalTokenCount": 30,
},
}
token_usage = provider._extract_passthrough_token_usage(
PassthroughAction.GEMINI_GENERATE_CONTENT, result
)
assert token_usage == {
"input_tokens": 10,
"output_tokens": 20,
"total_tokens": 30,
}
def test_litellm_extract_passthrough_token_usage_gemini_with_cached_tokens():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
result = {
"candidates": [{"content": {"parts": [{"text": "Hello"}]}}],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 20,
"totalTokenCount": 30,
"cachedContentTokenCount": 5,
},
}
token_usage = provider._extract_passthrough_token_usage(
PassthroughAction.GEMINI_GENERATE_CONTENT, result
)
assert token_usage == {
"input_tokens": 10,
"output_tokens": 20,
"total_tokens": 30,
"cache_read_input_tokens": 5,
}
def test_litellm_extract_passthrough_token_usage_no_usage():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
result = {"id": "chatcmpl-123", "choices": []}
token_usage = provider._extract_passthrough_token_usage(PassthroughAction.OPENAI_CHAT, result)
assert token_usage is None
def test_litellm_extract_streaming_token_usage_openai_format():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = (
b'data: {"id":"chatcmpl-123","usage":'
b'{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}\n'
)
result = provider._extract_streaming_token_usage(chunk)
assert result == {
"input_tokens": 10,
"output_tokens": 20,
"total_tokens": 30,
}
def test_litellm_extract_streaming_token_usage_anthropic_message_start():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = b'data: {"type":"message_start","message":{"usage":{"input_tokens":100}}}\n'
result = provider._extract_streaming_token_usage(chunk)
assert result == {"input_tokens": 100}
def test_litellm_extract_streaming_token_usage_anthropic_message_delta():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = b'data: {"type":"message_delta","usage":{"output_tokens":50}}\n'
result = provider._extract_streaming_token_usage(chunk)
# Method only returns chunk's usage; total is calculated by _stream_passthrough_with_usage
assert result == {"output_tokens": 50}
def test_litellm_extract_streaming_token_usage_gemini_format():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = (
b'data: {"candidates":[{"content":{}}],"usageMetadata":'
b'{"promptTokenCount":10,"candidatesTokenCount":20,"totalTokenCount":30}}\n'
)
result = provider._extract_streaming_token_usage(chunk)
assert result == {
"input_tokens": 10,
"output_tokens": 20,
"total_tokens": 30,
}
def test_litellm_extract_streaming_token_usage_gemini_with_cached_tokens():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = (
b'data: {"candidates":[{"content":{}}],"usageMetadata":'
b'{"promptTokenCount":10,"candidatesTokenCount":20,"totalTokenCount":30,"cachedContentTokenCount":5}}\n'
)
result = provider._extract_streaming_token_usage(chunk)
assert result == {
"input_tokens": 10,
"output_tokens": 20,
"total_tokens": 30,
"cache_read_input_tokens": 5,
}
def test_litellm_extract_streaming_token_usage_empty_chunk():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = b""
result = provider._extract_streaming_token_usage(chunk)
assert result == {}
def test_litellm_extract_streaming_token_usage_done_chunk():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = b"data: [DONE]\n"
result = provider._extract_streaming_token_usage(chunk)
assert result == {}
def test_litellm_extract_streaming_token_usage_invalid_json():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = b"data: {invalid json}\n"
result = provider._extract_streaming_token_usage(chunk)
assert result == {}
def test_litellm_extract_streaming_token_usage_responses_api():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
# Responses API returns usage in data.response.usage with input_tokens/output_tokens
chunk = (
b'data: {"type":"response.completed","response":{"id":"resp_123",'
b'"usage":{"input_tokens":9,"output_tokens":65,"total_tokens":74}}}\n'
)
result = provider._extract_streaming_token_usage(chunk)
assert result == {
"input_tokens": 9,
"output_tokens": 65,
"total_tokens": 74,
}
def test_litellm_extract_passthrough_token_usage_openai_with_cached_tokens():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
result = {
"id": "chatcmpl-123",
"usage": {
"prompt_tokens": 50,
"completion_tokens": 20,
"total_tokens": 70,
"prompt_tokens_details": {"cached_tokens": 30},
},
}
token_usage = provider._extract_passthrough_token_usage(PassthroughAction.OPENAI_CHAT, result)
assert token_usage == {
"input_tokens": 50,
"output_tokens": 20,
"total_tokens": 70,
"cache_read_input_tokens": 30,
}
def test_litellm_extract_passthrough_token_usage_anthropic_with_cached_tokens():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
result = {
"id": "msg_123",
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"cache_read_input_tokens": 25,
"cache_creation_input_tokens": 15,
},
}
token_usage = provider._extract_passthrough_token_usage(
PassthroughAction.ANTHROPIC_MESSAGES, result
)
assert token_usage == {
"input_tokens": 140,
"output_tokens": 50,
"total_tokens": 190,
"cache_read_input_tokens": 25,
"cache_creation_input_tokens": 15,
}
def test_litellm_extract_streaming_token_usage_openai_with_cached_tokens():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = (
b'data: {"id":"chatcmpl-123","usage":'
b'{"prompt_tokens":50,"completion_tokens":20,"total_tokens":70,'
b'"prompt_tokens_details":{"cached_tokens":30}}}\n'
)
result = provider._extract_streaming_token_usage(chunk)
assert result == {
"input_tokens": 50,
"output_tokens": 20,
"total_tokens": 70,
"cache_read_input_tokens": 30,
}
def test_litellm_extract_streaming_token_usage_anthropic_message_start_with_cached_tokens():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = (
b'data: {"type":"message_start","message":{"usage":'
b'{"input_tokens":100,"cache_read_input_tokens":25}}}\n'
)
result = provider._extract_streaming_token_usage(chunk)
assert result == {
"input_tokens": 100,
"cache_read_input_tokens": 25,
}
def test_litellm_extract_streaming_token_usage_responses_api_with_cached_tokens():
provider = LiteLLMProvider(EndpointConfig(**chat_config()))
chunk = (
b'data: {"type":"response.completed","response":{"id":"resp_123",'
b'"usage":{"input_tokens":100,"output_tokens":50,"total_tokens":150,'
b'"input_tokens_details":{"cached_tokens":40}}}}\n'
)
result = provider._extract_streaming_token_usage(chunk)
assert result == {
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150,
"cache_read_input_tokens": 40,
}