litellm/tests/unit/google_genai/test_google_genai_handler.py
yuneng-jiang f6882246d4
test: move tests/test_litellm root and small trees into tests/unit (#43186)
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: rename fork-flag to unit-flag now that it applies on every event

* test: move tests/test_litellm root and small trees into tests/unit

Pure renames, no content changes. Follow-up commits in this PR fix
references, merge the three files that already existed in tests/unit,
keep live-provider tests in tests/test_litellm and wire CI.

* test: carry tests/test_litellm conftest isolation into tests/unit

Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS,
proxy-URL and keychain env, and session-end client cleanup now reset for
unit tests too. The environment isolation owns its MonkeyPatch so a test's
own monkeypatch is undone before the model-cost teardown runs.

* test: merge, split and prune the moved root and small-tree tests

Merge batches/test_batch_utils.py and the chat_completions and messages
dispatch tests into the files that already existed in tests/unit. Keep
the live Gemini interactions tests, the async image-fetch format test and
the OpenAI embedding scorer test in tests/test_litellm since they need
real network or keys. Put test_router.py under tests/unit/test_router so
the existing package no longer shadows it. Delete eight tests the audit
found superseded by stronger ones kept in this move.

* ci: run the moved root and small-tree tests under their legacy flags

Add the misc and responses-caching-types flags to unit_selection.sh and
CircleCI, extend enterprise-routing and mcp-integration, and point the
legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest
and change classifier at the new paths.

* test: make the new tests/unit directories packages

tests/unit/test_package_layout.py requires every directory to carry an
__init__.py, and without one the moved and retained
test_litellm_responses_bridge.py modules collide on import.

* test: scope the unit socket block to tests/unit in shared sessions

The GHA shards collect the legacy test-path and the unit selection in one
pytest session. The unit conftest's loopback-only block leaked into legacy
modules that reach the network at import. The legacy conftest now lifts the
restriction at collect and setup time, and the unit conftest re-applies it
when collecting its own modules.

* test: give the shard-script tests their own GITHUB_OUTPUT

They only passed where the runner set it. The CircleCI unit job's env
allowlist drops it, so the script's redirect failed there.

* test: point the router and module-deletion checks at tests/unit

router_code_coverage and code_qa_check_tests only searched tests/test_litellm,
so the moved router tests no longer counted. The two silent-experiment tests
the audit deleted were the only direct callers of those methods; they are
replaced with tests that assert the forwarded shadow request and the
recursion guard.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 11:30:43 -07:00

259 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""
Test to verify the Google GenAI generate_content handler functionality
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm.google_genai.adapters.handler import GenerateContentToCompletionHandler
from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter
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}")