mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
* test: drop the cwd-relative sys.path.insert calls from the test suite
TQ003 stands at 1,077 across 1,058 files, and 1,015 of them are the same shape:
sys.path.insert(0, os.path.abspath("../..")) and its deeper siblings. The
argument resolves against the working directory rather than the file, so from
the repo root, where every job runs pytest, it inserts the directory two levels
above the checkout. It has never pointed at litellm. The package is installed
into the environment anyway, which is what actually makes the import work, and
what the rule's message has said all along.
Removing them leaves 1,634 imports of sys and os with no remaining reference,
and those go too, except where another test module imports the name back out of
the file. The rest of TQ003 is 62 call sites that resolve against __file__ or a
variable, which are a different question and are left alone.
Collection is identical either way: 45,871 tests and the same 51 pre-existing
collection errors before and after, and ruff reports no new undefined name.
* test: drop the duplicate imports the sys.path sweep exposed to F811
* test(pre-call-utils): restore the os import the new bedrock tests need
316 lines
12 KiB
Python
316 lines
12 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from typing import Any, AsyncIterator, Dict, List, Optional, Union
|
|
import pytest
|
|
|
|
|
|
import litellm
|
|
from litellm.google_genai import (
|
|
generate_content,
|
|
agenerate_content,
|
|
generate_content_stream,
|
|
agenerate_content_stream,
|
|
)
|
|
from google.genai.types import ContentDict, PartDict
|
|
from litellm.types.google_genai.main import GenerateContentResponse
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.types.utils import StandardLoggingPayload
|
|
|
|
|
|
def load_vertex_ai_credentials(model: str):
|
|
"""Load Vertex AI credentials for tests"""
|
|
# Define the path to the vertex_key.json file
|
|
if "vertex_ai" not in model:
|
|
return None
|
|
print("loading vertex ai credentials")
|
|
filepath = os.path.dirname(os.path.abspath(__file__))
|
|
vertex_key_path = filepath + "/vertex_key.json"
|
|
|
|
# Read the existing content of the file or create an empty dictionary
|
|
try:
|
|
with open(vertex_key_path, "r") as file:
|
|
# Read the file content
|
|
print("Read vertexai file path")
|
|
content = file.read()
|
|
|
|
# If the file is empty or not valid JSON, create an empty dictionary
|
|
if not content or not content.strip():
|
|
service_account_key_data = {}
|
|
else:
|
|
# Attempt to load the existing JSON content
|
|
file.seek(0)
|
|
service_account_key_data = json.load(file)
|
|
except FileNotFoundError:
|
|
# If the file doesn't exist, create an empty dictionary
|
|
service_account_key_data = {}
|
|
|
|
# Update the service_account_key_data with environment variables
|
|
private_key_id = os.environ.get("VERTEX_AI_PRIVATE_KEY_ID", "")
|
|
private_key = os.environ.get("VERTEX_AI_PRIVATE_KEY", "")
|
|
private_key = private_key.replace("\\n", "\n")
|
|
service_account_key_data["private_key_id"] = private_key_id
|
|
service_account_key_data["private_key"] = private_key
|
|
|
|
# Create a temporary file
|
|
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as temp_file:
|
|
# Write the updated content to the temporary files
|
|
json.dump(service_account_key_data, temp_file, indent=2)
|
|
|
|
# Export the temporary file as GOOGLE_APPLICATION_CREDENTIALS
|
|
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = os.path.abspath(temp_file.name)
|
|
|
|
return os.path.abspath(temp_file.name)
|
|
|
|
|
|
class TestCustomLogger(CustomLogger):
|
|
def __init__(
|
|
self,
|
|
):
|
|
self.standard_logging_object: Optional[StandardLoggingPayload] = None
|
|
|
|
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
|
|
print("in async_log_success_event")
|
|
print("kwargs=", json.dumps(kwargs, indent=4, default=str))
|
|
self.standard_logging_object = kwargs["standard_logging_object"]
|
|
pass
|
|
|
|
|
|
class BaseGoogleGenAITest:
|
|
"""Base class for Google GenAI generate content tests to reduce code duplication"""
|
|
|
|
@property
|
|
def model_config(self) -> Dict[str, Any]:
|
|
"""Override in subclasses to provide model-specific configuration"""
|
|
raise NotImplementedError("Subclasses must implement model_config")
|
|
|
|
@property
|
|
def _temp_files_to_cleanup(self):
|
|
"""Lazy initialization of temp files list"""
|
|
if not hasattr(self, "_temp_files_list"):
|
|
self._temp_files_list = []
|
|
return self._temp_files_list
|
|
|
|
def cleanup_temp_files(self):
|
|
"""Clean up any temporary files created during testing"""
|
|
for temp_file in self._temp_files_to_cleanup:
|
|
try:
|
|
os.unlink(temp_file)
|
|
except OSError:
|
|
pass # File might already be deleted
|
|
self._temp_files_to_cleanup.clear()
|
|
|
|
def _validate_non_streaming_response(self, response: Any):
|
|
"""Validate non-streaming response structure"""
|
|
# Handle type checking - response should be a GenerateContentResponse for non-streaming
|
|
if isinstance(response, AsyncIterator):
|
|
pytest.fail("Expected non-streaming response but got AsyncIterator")
|
|
|
|
assert isinstance(
|
|
response, GenerateContentResponse
|
|
), f"Expected GenerateContentResponse, got {type(response)}"
|
|
print(f"Response: {response.model_dump_json(indent=4)}")
|
|
|
|
# Basic validation - adjust based on actual Google GenAI response structure
|
|
# The exact structure may vary, so we'll be flexible here
|
|
assert response is not None, "Response should not be None"
|
|
|
|
def _validate_streaming_response(self, chunks: List[Any]):
|
|
"""Validate streaming response chunks"""
|
|
assert isinstance(chunks, list), f"Expected list of chunks, got {type(chunks)}"
|
|
assert len(chunks) >= 0, "Should have at least 0 chunks"
|
|
print(f"Total chunks received: {len(chunks)}")
|
|
|
|
def _validate_standard_logging_payload(
|
|
self, slp: StandardLoggingPayload, response: Any
|
|
):
|
|
"""
|
|
Validate that a StandardLoggingPayload object matches the expected response for Google GenAI
|
|
|
|
Args:
|
|
slp (StandardLoggingPayload): The standard logging payload object to validate
|
|
response: The Google GenAI response to compare against
|
|
"""
|
|
# Validate payload exists
|
|
assert slp is not None, "Standard logging payload should not be None"
|
|
|
|
# Validate basic structure
|
|
assert (
|
|
"prompt_tokens" in slp
|
|
), "Standard logging payload should have prompt_tokens"
|
|
assert (
|
|
"completion_tokens" in slp
|
|
), "Standard logging payload should have completion_tokens"
|
|
assert (
|
|
"total_tokens" in slp
|
|
), "Standard logging payload should have total_tokens"
|
|
assert (
|
|
"response_cost" in slp
|
|
), "Standard logging payload should have response_cost"
|
|
|
|
# Validate token counts are reasonable (non-negative numbers)
|
|
assert slp["prompt_tokens"] >= 0, "Prompt tokens should be non-negative"
|
|
assert slp["completion_tokens"] >= 0, "Completion tokens should be non-negative"
|
|
assert slp["total_tokens"] >= 0, "Total tokens should be non-negative"
|
|
|
|
# Validate spend
|
|
assert slp["response_cost"] >= 0, "Response cost should be non-negative"
|
|
|
|
print(
|
|
f"Standard logging payload validation passed: prompt_tokens={slp['prompt_tokens']}, completion_tokens={slp['completion_tokens']}, total_tokens={slp['total_tokens']}, cost={slp['response_cost']}"
|
|
)
|
|
|
|
@pytest.mark.parametrize("is_async", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_non_streaming_base(self, is_async: bool):
|
|
"""Base test for non-streaming requests (parametrized for sync/async)"""
|
|
request_params = self.model_config
|
|
contents = ContentDict(
|
|
parts=[PartDict(text="Hello, can you tell me a short joke?")],
|
|
role="user",
|
|
)
|
|
temp_file_path = load_vertex_ai_credentials(model=request_params["model"])
|
|
if temp_file_path:
|
|
self._temp_files_to_cleanup.append(temp_file_path)
|
|
|
|
litellm._turn_on_debug()
|
|
|
|
print(
|
|
f"Testing {'async' if is_async else 'sync'} non-streaming with model config: {request_params}"
|
|
)
|
|
print(f"Contents: {contents}")
|
|
|
|
if is_async:
|
|
print("\n--- Testing async agenerate_content ---")
|
|
response = await agenerate_content(contents=contents, **request_params)
|
|
else:
|
|
print("\n--- Testing sync generate_content ---")
|
|
response = generate_content(contents=contents, **request_params)
|
|
|
|
print(
|
|
f"{'Async' if is_async else 'Sync'} response: {json.dumps(response, indent=2, default=str)}"
|
|
)
|
|
self._validate_non_streaming_response(response)
|
|
|
|
return response
|
|
|
|
@pytest.mark.parametrize("is_async", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_base(self, is_async: bool):
|
|
"""Base test for streaming requests (parametrized for sync/async)"""
|
|
request_params = self.model_config
|
|
temp_file_path = load_vertex_ai_credentials(model=request_params["model"])
|
|
if temp_file_path:
|
|
self._temp_files_to_cleanup.append(temp_file_path)
|
|
contents = ContentDict(
|
|
parts=[PartDict(text="Hello, can you tell me a short joke?")],
|
|
role="user",
|
|
)
|
|
|
|
print(
|
|
f"Testing {'async' if is_async else 'sync'} streaming with model config: {request_params}"
|
|
)
|
|
print(f"Contents: {contents}")
|
|
|
|
chunks = []
|
|
|
|
if is_async:
|
|
print("\n--- Testing async agenerate_content_stream ---")
|
|
response = await agenerate_content_stream(
|
|
contents=contents, **request_params
|
|
)
|
|
async for chunk in response:
|
|
print(f"Async chunk: {chunk}")
|
|
chunks.append(chunk)
|
|
else:
|
|
print("\n--- Testing sync generate_content_stream ---")
|
|
response = generate_content_stream(contents=contents, **request_params)
|
|
for chunk in response:
|
|
print(f"Sync chunk: {chunk}")
|
|
chunks.append(chunk)
|
|
|
|
self._validate_streaming_response(chunks)
|
|
|
|
return chunks
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_non_streaming_with_logging(self):
|
|
"""Test async non-streaming Google GenAI generate content with logging"""
|
|
litellm._turn_on_debug()
|
|
litellm.logging_callback_manager._reset_all_callbacks()
|
|
litellm.set_verbose = True
|
|
test_custom_logger = TestCustomLogger()
|
|
litellm.callbacks = [test_custom_logger]
|
|
|
|
request_params = self.model_config
|
|
temp_file_path = load_vertex_ai_credentials(model=request_params["model"])
|
|
if temp_file_path:
|
|
self._temp_files_to_cleanup.append(temp_file_path)
|
|
contents = ContentDict(
|
|
parts=[PartDict(text="Hello, can you tell me a short joke?")],
|
|
role="user",
|
|
)
|
|
|
|
print("\n--- Testing async agenerate_content with logging ---")
|
|
response = await agenerate_content(contents=contents, **request_params)
|
|
|
|
print("Google GenAI response=", json.dumps(response, indent=4, default=str))
|
|
|
|
print("sleeping for 5 seconds...")
|
|
await asyncio.sleep(5)
|
|
print(
|
|
"standard logging payload=",
|
|
json.dumps(
|
|
test_custom_logger.standard_logging_object, indent=4, default=str
|
|
),
|
|
)
|
|
|
|
assert response is not None
|
|
assert test_custom_logger.standard_logging_object is not None
|
|
|
|
self._validate_standard_logging_payload(
|
|
test_custom_logger.standard_logging_object, response
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_streaming_with_logging(self):
|
|
"""Test async streaming Google GenAI generate content with logging"""
|
|
litellm._turn_on_debug()
|
|
litellm.set_verbose = True
|
|
litellm.logging_callback_manager._reset_all_callbacks()
|
|
test_custom_logger = TestCustomLogger()
|
|
litellm.callbacks = [test_custom_logger]
|
|
|
|
request_params = self.model_config
|
|
temp_file_path = load_vertex_ai_credentials(model=request_params["model"])
|
|
if temp_file_path:
|
|
self._temp_files_to_cleanup.append(temp_file_path)
|
|
contents = ContentDict(
|
|
parts=[PartDict(text="Hello, can you tell me a short joke?")],
|
|
role="user",
|
|
)
|
|
|
|
print("\n--- Testing async agenerate_content_stream with logging ---")
|
|
response = await agenerate_content_stream(contents=contents, **request_params)
|
|
|
|
chunks = []
|
|
async for chunk in response:
|
|
print(f"Google GenAI chunk: {chunk}")
|
|
chunks.append(chunk)
|
|
|
|
print("sleeping for 5 seconds...")
|
|
await asyncio.sleep(5)
|
|
print(
|
|
"standard logging payload=",
|
|
json.dumps(
|
|
test_custom_logger.standard_logging_object, indent=4, default=str
|
|
),
|
|
)
|
|
|
|
assert len(chunks) >= 0
|
|
assert test_custom_logger.standard_logging_object is not None
|
|
|
|
self._validate_standard_logging_payload(
|
|
test_custom_logger.standard_logging_object, chunks
|
|
)
|