mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
* fix(vertex_ai): route Model Garden models through the generateContent adapter Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(vertex_ai): annotate generateContent route test parameters Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(vertex_ai): type litellm_params in get_vertex_ai_model_route * fix(vertex_ai): bill and log Model Garden generateContent calls like chat completions Streamed Model Garden calls through the generateContent adapter now request stream usage where the deployment supports it, keep cached-token details, and look up the provider-relative cost key, so they bill like the non-stream call. The adapter forwards router metadata, passes upstream errors through with their status, countTokens falls back to local counting for non-Gemini routes, and the native Gemini config is limited to the Gemini routes. * fix(google_genai): keep adapter stream usage, admin opt-out, single failure count, and base_model routing Streamed generateContent through the chat adapter now ends with the upstream usage, honors always_include_stream_usage false and the caller's stream_options, counts one failure per failed call, and routes Vertex deployments by their base_model like chat does * fix(google_genai): ask for stream usage on every adapter stream so Claude streams end with usage * test(google_genai): expect the trailing usage chunk on bridged OpenAI streams The OpenAI bridge test now sends the usage chunk OpenAI returns for include_usage and expects it translated into usageMetadata. The response_format support helper goes back to its single-purpose form since its only other caller is gone. * fix(google_genai): end adapter streams with one STOP event carrying usage, and keep upstream usage after the finish chunk * fix(google_genai): send the held STOP event when no usage arrives, keep a caller's stream_options, and route countTokens like generateContent * test(integration): cover the moved Vertex routes, adapter stream usage, the shared iterator and usage settings over the wire * test(integration): add cache-hit twins for the stream usage setting and non-stream cells for every iterator member --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
613 lines
24 KiB
Python
613 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Test to verify the Google GenAI generate_content handler functionality
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
import litellm
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
|
|
from litellm.google_genai.adapters.handler import GenerateContentToCompletionHandler
|
|
from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter, GoogleGenAIStreamWrapper
|
|
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices, Usage
|
|
|
|
|
|
def test_stream_response_when_stream_requested_sync():
|
|
"""
|
|
Test that when a stream response is returned and streaming was requested,
|
|
the sync handler correctly transforms it to generate_content streaming format.
|
|
"""
|
|
# Mock a stream response
|
|
mock_stream = MagicMock()
|
|
mock_stream.__iter__ = MagicMock(return_value=iter([]))
|
|
|
|
# Mock the GoogleGenAIAdapter's translate_completion_output_params_streaming method
|
|
with patch.object(
|
|
GoogleGenAIAdapter,
|
|
"translate_completion_output_params_streaming",
|
|
return_value=mock_stream,
|
|
) as mock_translate:
|
|
with patch("litellm.completion", return_value=mock_stream):
|
|
# Call the handler with stream=True
|
|
result = GenerateContentToCompletionHandler.generate_content_handler(
|
|
model="gemini-pro",
|
|
contents=[{"role": "user", "parts": [{"text": "Hello"}]}],
|
|
litellm_params={}, # Empty dict for params
|
|
stream=True,
|
|
)
|
|
|
|
# Verify that translate_completion_output_params_streaming was called
|
|
mock_translate.assert_called_once_with(mock_stream)
|
|
# Verify the result is the transformed stream
|
|
assert result == mock_stream
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_response_when_stream_requested_async():
|
|
"""
|
|
Test that when a stream response is returned and streaming was requested,
|
|
the async handler correctly transforms it to generate_content streaming format.
|
|
"""
|
|
# Mock a stream response
|
|
mock_stream = MagicMock()
|
|
mock_stream.__aiter__ = AsyncMock(return_value=iter([])) # Return an empty async iterator
|
|
|
|
# Mock the GoogleGenAIAdapter's translate_completion_output_params_streaming method
|
|
with patch.object(
|
|
GoogleGenAIAdapter,
|
|
"translate_completion_output_params_streaming",
|
|
return_value=mock_stream,
|
|
) as mock_translate:
|
|
with patch("litellm.acompletion", return_value=mock_stream):
|
|
# Call the handler with stream=True
|
|
result = await GenerateContentToCompletionHandler.async_generate_content_handler(
|
|
model="gemini-pro",
|
|
contents=[{"role": "user", "parts": [{"text": "Hello"}]}],
|
|
litellm_params={}, # Empty dict for params
|
|
stream=True,
|
|
)
|
|
|
|
# Verify that translate_completion_output_params_streaming was called
|
|
mock_translate.assert_called_once_with(mock_stream)
|
|
# Verify the result is the transformed stream
|
|
assert result == mock_stream
|
|
|
|
|
|
def test_stream_transformation_error_sync():
|
|
"""
|
|
Test that when a stream transformation fails, the sync handler raises a ValueError.
|
|
"""
|
|
# Mock a stream response
|
|
mock_stream = MagicMock()
|
|
mock_stream.__iter__ = MagicMock(return_value=iter([]))
|
|
|
|
# Mock the GoogleGenAIAdapter's translate_completion_output_params_streaming method to return None
|
|
with patch.object(
|
|
GoogleGenAIAdapter,
|
|
"translate_completion_output_params_streaming",
|
|
return_value=None,
|
|
):
|
|
# Patch litellm.completion directly to prevent real API calls
|
|
with patch("litellm.completion", return_value=mock_stream):
|
|
# Call the handler with stream=True and expect a ValueError
|
|
with pytest.raises(ValueError, match="Failed to transform streaming response"):
|
|
GenerateContentToCompletionHandler.generate_content_handler(
|
|
model="gemini-pro",
|
|
contents=[{"role": "user", "parts": [{"text": "Hello"}]}],
|
|
litellm_params={}, # Empty dict for params
|
|
stream=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_transformation_error_async():
|
|
"""
|
|
Test that when a stream transformation fails, the async handler raises a ValueError.
|
|
"""
|
|
# Mock a stream response
|
|
mock_stream = MagicMock()
|
|
mock_stream.__aiter__ = AsyncMock(return_value=mock_stream)
|
|
|
|
# Mock the GoogleGenAIAdapter's translate_completion_output_params_streaming method to return None
|
|
with patch.object(
|
|
GoogleGenAIAdapter,
|
|
"translate_completion_output_params_streaming",
|
|
return_value=None,
|
|
):
|
|
# Mock litellm.acompletion at the module level where it's imported
|
|
# We need to patch it in the handler module, not in litellm itself
|
|
with patch("litellm.google_genai.adapters.handler.litellm") as mock_litellm:
|
|
# Use AsyncMock for async function
|
|
mock_litellm.acompletion = AsyncMock(return_value=mock_stream)
|
|
# Call the handler with stream=True and expect a ValueError
|
|
with pytest.raises(ValueError, match="Failed to transform streaming response"):
|
|
await GenerateContentToCompletionHandler.async_generate_content_handler(
|
|
model="gemini-pro",
|
|
contents=[{"role": "user", "parts": [{"text": "Hello"}]}],
|
|
litellm_params={}, # Empty dict for params
|
|
stream=True,
|
|
)
|
|
|
|
|
|
def test_citation_metadata_transformation():
|
|
"""
|
|
Test that citationMetadata.citationSources is properly transformed to citationMetadata.citations
|
|
to avoid Pydantic validation errors.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
|
|
import httpx
|
|
|
|
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
|
from litellm.llms.gemini.google_genai.transformation import GoogleGenAIConfig
|
|
|
|
# Create a mock response with citationMetadata.citationSources (the problematic format)
|
|
mock_response_data = {
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [{"text": "This is a video analysis response with citation metadata."}],
|
|
"role": "model",
|
|
},
|
|
"finishReason": "STOP",
|
|
"index": 0,
|
|
"safetyRatings": [],
|
|
"citationMetadata": {
|
|
"citationSources": [
|
|
{
|
|
"startIndex": 5848,
|
|
"endIndex": 5900,
|
|
"uri": "https://example.com/video-source",
|
|
"license": "MIT",
|
|
"title": "Video Analysis Source",
|
|
"publicationDate": "2024-01-15",
|
|
},
|
|
{
|
|
"startIndex": 6200,
|
|
"endIndex": 6250,
|
|
"uri": "https://another-source.com/reference",
|
|
"license": "CC-BY",
|
|
"title": "Another Reference",
|
|
"publicationDate": "2024-02-01",
|
|
},
|
|
]
|
|
},
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 150,
|
|
"candidatesTokenCount": 200,
|
|
"totalTokenCount": 350,
|
|
},
|
|
"responseId": "test-response-123",
|
|
}
|
|
|
|
# Create mock httpx response
|
|
mock_httpx_response = MagicMock(spec=httpx.Response)
|
|
mock_httpx_response.json.return_value = mock_response_data
|
|
mock_httpx_response.status_code = 200
|
|
mock_httpx_response.headers = {}
|
|
|
|
# Create logging object
|
|
logging_obj = LiteLLMLoggingObj(
|
|
model="gemini-2.5-flash",
|
|
messages=[],
|
|
stream=False,
|
|
call_type="generate_content",
|
|
start_time=1234567890,
|
|
litellm_call_id="test-call-123",
|
|
function_id="test-function-123",
|
|
)
|
|
|
|
# Create GoogleGenAI config
|
|
config = GoogleGenAIConfig()
|
|
|
|
# Test the transformation
|
|
try:
|
|
result = config.transform_generate_content_response(
|
|
model="gemini-2.5-flash",
|
|
raw_response=mock_httpx_response,
|
|
logging_obj=logging_obj,
|
|
)
|
|
|
|
# Verify the transformation worked
|
|
assert result is not None
|
|
|
|
# Check that citationSources was transformed to citations
|
|
if hasattr(result, "candidates") and result.candidates:
|
|
candidate = result.candidates[0]
|
|
if hasattr(candidate, "citationMetadata") and candidate.citationMetadata:
|
|
# The citationMetadata should now have 'citations' instead of 'citationSources'
|
|
citation_metadata = candidate.citationMetadata
|
|
|
|
# Check that citations field exists
|
|
assert hasattr(citation_metadata, "citations"), "citations field should exist after transformation"
|
|
|
|
# Verify the citations data is preserved
|
|
if hasattr(citation_metadata, "citations") and citation_metadata.citations:
|
|
assert len(citation_metadata.citations) == 2, "Should have 2 citations"
|
|
assert citation_metadata.citations[0]["uri"] == "https://example.com/video-source"
|
|
assert citation_metadata.citations[1]["uri"] == "https://another-source.com/reference"
|
|
|
|
print("✅ Citation metadata transformation test passed!")
|
|
|
|
except Exception as e:
|
|
pytest.fail(f"Citation metadata transformation failed: {e}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_content_adapter_preserves_proxy_server_request():
|
|
"""
|
|
Ensure GenerateContentToCompletionHandler forwards proxy_server_request
|
|
to the downstream completion call so proxy spend logging captures the request body.
|
|
"""
|
|
from litellm.types.router import GenericLiteLLMParams
|
|
from litellm.types.utils import Choices, Message, ModelResponse
|
|
|
|
handler = GenerateContentToCompletionHandler()
|
|
|
|
dummy_proxy_request: dict[str, object] = {
|
|
"url": "http://localhost:4000/v1beta/models/gemini-2.0-flash:generateContent",
|
|
"method": "POST",
|
|
"headers": {"content-type": "application/json"},
|
|
"body": {"contents": [{"role": "user", "parts": [{"text": "Hello, world!"}]}]},
|
|
}
|
|
|
|
gemini_data: list[dict[str, object]] = [{"role": "user", "parts": [{"text": "Hello, world!"}]}]
|
|
|
|
mock_response = ModelResponse(choices=[Choices(message=Message(content="Hi!", role="assistant"))])
|
|
|
|
with patch(
|
|
"litellm.google_genai.adapters.handler.litellm.acompletion",
|
|
new_callable=AsyncMock,
|
|
) as mock_acompletion:
|
|
mock_acompletion.return_value = mock_response
|
|
|
|
await handler.async_generate_content_handler(
|
|
model="gemini-2.0-flash",
|
|
contents=gemini_data,
|
|
litellm_params=GenericLiteLLMParams(),
|
|
proxy_server_request=dummy_proxy_request,
|
|
metadata={"source": "unit_test"},
|
|
)
|
|
|
|
assert mock_acompletion.called, "Inner acompletion was not called"
|
|
called_kwargs = mock_acompletion.call_args.kwargs
|
|
|
|
assert "proxy_server_request" in called_kwargs, "proxy_server_request was dropped from completion_kwargs"
|
|
assert called_kwargs["proxy_server_request"] == dummy_proxy_request
|
|
|
|
|
|
_CHAT_SSE = (
|
|
'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m",'
|
|
'"choices":[{"index":0,"delta":{"role":"assistant","content":"144"},"finish_reason":null}]}\n\n'
|
|
'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m",'
|
|
'"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n'
|
|
"data: [DONE]\n\n"
|
|
)
|
|
_CHAT_BODY: dict[str, object] = {
|
|
"id": "c",
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "m",
|
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "144"}, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4},
|
|
}
|
|
|
|
|
|
_CHAT_SSE_WITH_USAGE = _CHAT_SSE.replace(
|
|
"data: [DONE]",
|
|
'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"m","choices":[],'
|
|
'"usage":{"prompt_tokens":340,"completion_tokens":1,"total_tokens":341}}\n\n'
|
|
"data: [DONE]",
|
|
)
|
|
|
|
_ANTHROPIC_SSE: Final = "".join(
|
|
f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
for event, data in (
|
|
(
|
|
"message_start",
|
|
{
|
|
"type": "message_start",
|
|
"message": {
|
|
"id": "msg_1",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": "claude-haiku-4-5",
|
|
"content": [],
|
|
"stop_reason": None,
|
|
"usage": {"input_tokens": 20, "output_tokens": 1},
|
|
},
|
|
},
|
|
),
|
|
(
|
|
"content_block_start",
|
|
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
|
),
|
|
(
|
|
"content_block_delta",
|
|
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "144"}},
|
|
),
|
|
("content_block_stop", {"type": "content_block_stop", "index": 0}),
|
|
(
|
|
"message_delta",
|
|
{"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 5}},
|
|
),
|
|
("message_stop", {"type": "message_stop"}),
|
|
)
|
|
)
|
|
|
|
|
|
class _FailureCallTypes(CustomLogger):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.call_types: list[str] = []
|
|
|
|
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
|
self.call_types.append(kwargs["call_type"])
|
|
|
|
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
|
self.call_types.append(kwargs["call_type"])
|
|
|
|
|
|
class _SuccessPayloads(CustomLogger):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.model_groups: list[str | None] = []
|
|
|
|
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
self.model_groups.append(kwargs["standard_logging_object"]["model_group"])
|
|
|
|
|
|
@pytest.fixture
|
|
def _httpx_transport(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
|
|
|
|
async def _drain(stream: object) -> None:
|
|
async for _ in stream:
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_content_adapter_keeps_provider_error_status(_httpx_transport: None):
|
|
from litellm.types.router import GenericLiteLLMParams
|
|
|
|
with respx.mock() as upstream:
|
|
upstream.post("http://upstream.test/v1/chat/completions").mock(
|
|
return_value=httpx.Response(400, json={"error": {"message": "max_tokens must be positive"}})
|
|
)
|
|
with pytest.raises(litellm.BadRequestError) as exc_info:
|
|
await GenerateContentToCompletionHandler.async_generate_content_handler(
|
|
model="hosted_vllm/grok",
|
|
contents=[{"role": "user", "parts": [{"text": "Hello"}]}],
|
|
litellm_params=GenericLiteLLMParams(api_base="http://upstream.test/v1", api_key="k"),
|
|
config={"maxOutputTokens": -5},
|
|
)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_generate_content_adapter_logs_router_model_group(
|
|
_httpx_transport: None, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
from litellm.types.router import GenericLiteLLMParams
|
|
|
|
success_payloads = _SuccessPayloads()
|
|
monkeypatch.setattr(litellm, "callbacks", [success_payloads])
|
|
|
|
with respx.mock() as upstream:
|
|
upstream.post("http://upstream.test/v1/chat/completions").mock(
|
|
return_value=httpx.Response(200, json=_CHAT_BODY)
|
|
)
|
|
await GenerateContentToCompletionHandler.async_generate_content_handler(
|
|
model="hosted_vllm/grok",
|
|
contents=[{"role": "user", "parts": [{"text": "Hello"}]}],
|
|
litellm_params=GenericLiteLLMParams(api_base="http://upstream.test/v1", api_key="k"),
|
|
litellm_metadata={"model_group": "grok-group"},
|
|
)
|
|
await asyncio.sleep(0.5)
|
|
|
|
assert "grok-group" in success_payloads.model_groups
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("stream", "requested_stream_options", "expected_stream_options"),
|
|
[
|
|
(True, None, {"include_usage": True}),
|
|
(True, {"include_usage": True}, {"include_usage": True}),
|
|
(True, {"include_usage": False}, None),
|
|
(False, None, None),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_generate_content_adapter_asks_openai_compatible_upstream_for_stream_usage(
|
|
_httpx_transport: None,
|
|
stream: bool,
|
|
requested_stream_options: dict[str, bool] | None,
|
|
expected_stream_options: dict[str, bool] | None,
|
|
):
|
|
from litellm.types.router import GenericLiteLLMParams
|
|
|
|
with respx.mock() as upstream:
|
|
route = upstream.post("http://upstream.test/v1/chat/completions").mock(
|
|
return_value=(
|
|
httpx.Response(200, text=_CHAT_SSE, headers={"content-type": "text/event-stream"})
|
|
if stream
|
|
else httpx.Response(200, json=_CHAT_BODY)
|
|
)
|
|
)
|
|
response = await GenerateContentToCompletionHandler.async_generate_content_handler(
|
|
model="hosted_vllm/grok",
|
|
contents=[{"role": "user", "parts": [{"text": "Hello"}]}],
|
|
litellm_params=GenericLiteLLMParams(api_base="http://upstream.test/v1", api_key="k"),
|
|
stream=stream,
|
|
**({} if requested_stream_options is None else {"stream_options": requested_stream_options}),
|
|
)
|
|
if stream:
|
|
await _drain(response)
|
|
|
|
assert json.loads(route.calls[0].request.content).get("stream_options") == expected_stream_options
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model", "api_base", "upstream_url", "upstream_sse", "prompt_tokens"),
|
|
[
|
|
(
|
|
"hosted_vllm/grok",
|
|
"http://upstream.test/v1",
|
|
"http://upstream.test/v1/chat/completions",
|
|
_CHAT_SSE_WITH_USAGE,
|
|
340,
|
|
),
|
|
("anthropic/claude-haiku-4-5", "http://upstream.test", "http://upstream.test/v1/messages", _ANTHROPIC_SSE, 20),
|
|
],
|
|
ids=["openai_compatible", "anthropic"],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_generate_content_adapter_stream_ends_with_upstream_usage(
|
|
_httpx_transport: None, model: str, api_base: str, upstream_url: str, upstream_sse: str, prompt_tokens: int
|
|
):
|
|
payloads: Final = await _adapter_stream_payloads(model, api_base, upstream_url, upstream_sse)
|
|
|
|
assert payloads[-1]["candidates"][0]["finishReason"] == "STOP"
|
|
assert payloads[-1]["usageMetadata"]["promptTokenCount"] == prompt_tokens
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model", "api_base", "upstream_url", "upstream_sse"),
|
|
[
|
|
("hosted_vllm/grok", "http://upstream.test/v1", "http://upstream.test/v1/chat/completions", _CHAT_SSE),
|
|
("anthropic/claude-haiku-4-5", "http://upstream.test", "http://upstream.test/v1/messages", _ANTHROPIC_SSE),
|
|
],
|
|
ids=["openai_compatible", "anthropic"],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_generate_content_adapter_stream_without_usage_still_ends_with_stop(
|
|
_httpx_transport: None, model: str, api_base: str, upstream_url: str, upstream_sse: str
|
|
):
|
|
payloads: Final = await _adapter_stream_payloads(
|
|
model, api_base, upstream_url, upstream_sse, stream_options={"include_usage": False}
|
|
)
|
|
|
|
assert [payload["candidates"][0].get("finishReason") for payload in payloads][-1] == "STOP"
|
|
|
|
|
|
async def _adapter_stream_payloads(
|
|
model: str, api_base: str, upstream_url: str, upstream_sse: str, **kwargs: object
|
|
):
|
|
from litellm.types.router import GenericLiteLLMParams
|
|
|
|
with respx.mock() as upstream:
|
|
upstream.post(upstream_url).mock(
|
|
return_value=httpx.Response(200, text=upstream_sse, headers={"content-type": "text/event-stream"})
|
|
)
|
|
response = await GenerateContentToCompletionHandler.async_generate_content_handler(
|
|
model=model,
|
|
contents=[{"role": "user", "parts": [{"text": "Hello"}]}],
|
|
litellm_params=GenericLiteLLMParams(api_base=api_base, api_key="k"),
|
|
stream=True,
|
|
**kwargs,
|
|
)
|
|
events: Final = [chunk async for chunk in response]
|
|
|
|
return [
|
|
json.loads(line.removeprefix("data: "))
|
|
for line in b"".join(events).decode().splitlines()
|
|
if line.startswith("data: {")
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("entrypoint", ["agenerate_content", "agenerate_content_stream"])
|
|
@pytest.mark.asyncio
|
|
async def test_generate_content_adapter_failure_is_logged_once(
|
|
_httpx_transport: None, monkeypatch: pytest.MonkeyPatch, entrypoint: str
|
|
):
|
|
failures = _FailureCallTypes()
|
|
monkeypatch.setattr(litellm, "callbacks", [failures])
|
|
|
|
with respx.mock() as upstream:
|
|
upstream.post("http://upstream.test/v1/chat/completions").mock(
|
|
return_value=httpx.Response(400, json={"error": {"message": "max_tokens must be positive"}})
|
|
)
|
|
with pytest.raises(litellm.BadRequestError):
|
|
await getattr(litellm.google_genai, entrypoint)(
|
|
model="hosted_vllm/grok",
|
|
contents=[{"role": "user", "parts": [{"text": "Hello"}]}],
|
|
api_base="http://upstream.test/v1",
|
|
api_key="k",
|
|
)
|
|
await asyncio.sleep(0.5)
|
|
|
|
assert failures.call_types == ["acompletion"]
|
|
|
|
|
|
def test_sync_generate_content_adapter_failure_is_logged_once(_httpx_transport: None, monkeypatch: pytest.MonkeyPatch):
|
|
failures = _FailureCallTypes()
|
|
monkeypatch.setattr(litellm, "callbacks", [failures])
|
|
|
|
with respx.mock() as upstream:
|
|
upstream.post("http://upstream.test/v1/chat/completions").mock(
|
|
return_value=httpx.Response(400, json={"error": {"message": "max_tokens must be positive"}})
|
|
)
|
|
with pytest.raises(litellm.BadRequestError):
|
|
litellm.google_genai.generate_content(
|
|
model="hosted_vllm/grok",
|
|
contents=[{"role": "user", "parts": [{"text": "Hello"}]}],
|
|
api_base="http://upstream.test/v1",
|
|
api_key="k",
|
|
)
|
|
time.sleep(0.5)
|
|
|
|
assert failures.call_types == ["completion"]
|
|
|
|
|
|
def _stream_chunk(content: str | None, finish_reason: str | None, usage: Usage | None = None) -> ModelResponseStream:
|
|
return ModelResponseStream(
|
|
choices=[StreamingChoices(index=0, delta=Delta(content=content), finish_reason=finish_reason)],
|
|
**({} if usage is None else {"usage": usage}),
|
|
)
|
|
|
|
|
|
_FINISH_WITHOUT_USAGE: Final = (_stream_chunk("144", None), _stream_chunk(None, "stop"))
|
|
_FINISH_WITH_USAGE: Final = (
|
|
_stream_chunk("144", None),
|
|
_stream_chunk(None, "stop", Usage(prompt_tokens=20, completion_tokens=1, total_tokens=21)),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("chunks", "prompt_tokens"), [(_FINISH_WITHOUT_USAGE, 0), (_FINISH_WITH_USAGE, 20)], ids=["no_usage", "usage"]
|
|
)
|
|
def test_stream_wrapper_iteration_ends_with_one_stop_event(chunks: tuple[ModelResponseStream, ...], prompt_tokens: int):
|
|
events: Final = list(GoogleGenAIStreamWrapper(completion_stream=iter(chunks)))
|
|
|
|
assert [event["candidates"][0].get("finishReason") for event in events] == [None, "STOP"]
|
|
assert events[-1]["usageMetadata"]["promptTokenCount"] == prompt_tokens
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("chunks", "prompt_tokens"), [(_FINISH_WITHOUT_USAGE, 0), (_FINISH_WITH_USAGE, 20)], ids=["no_usage", "usage"]
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_stream_wrapper_async_iteration_ends_with_one_stop_event(
|
|
chunks: tuple[ModelResponseStream, ...], prompt_tokens: int
|
|
):
|
|
async def completion_stream():
|
|
for chunk in chunks:
|
|
yield chunk
|
|
|
|
events: Final = [event async for event in GoogleGenAIStreamWrapper(completion_stream=completion_stream())]
|
|
|
|
assert [event["candidates"][0].get("finishReason") for event in events] == [None, "STOP"]
|
|
assert events[-1]["usageMetadata"]["promptTokenCount"] == prompt_tokens
|