[Fix] CI/CD - litellm_mapped_tests_llms | litellm_mapped_tests_core | caching_unit_tests (#18197)

This commit is contained in:
Alexsander Hamir
2025-12-18 08:49:23 -08:00
committed by GitHub
parent 28821427ce
commit f353bb6dba
15 changed files with 243 additions and 86 deletions
+48 -2
View File
@@ -10,6 +10,7 @@ Has 4 primary methods:
import ast
import asyncio
import hashlib
import inspect
import json
import time
@@ -159,6 +160,22 @@ class RedisCache(BaseCache):
"Error connecting to Async Redis client - {}".format(str(e)),
extra={"error": str(e)},
)
# Non-blocking service health monitoring for async Redis init failures
try:
loop = asyncio.get_running_loop()
start_time = time.time()
end_time = start_time
loop.create_task(
self.service_logger_obj.async_service_failure_hook(
service=ServiceTypes.REDIS,
duration=end_time - start_time,
error=e,
call_type="redis_async_ping",
)
)
except Exception:
# If no event loop or hook fails, ignore to keep init non-blocking
pass
### SYNC HEALTH PING ###
try:
@@ -168,12 +185,40 @@ class RedisCache(BaseCache):
verbose_logger.error(
"Error connecting to Sync Redis client", extra={"error": str(e)}
)
# Non-blocking service health monitoring for sync Redis init failures
try:
loop = asyncio.get_running_loop()
start_time = time.time()
end_time = start_time
loop.create_task(
self.service_logger_obj.async_service_failure_hook(
service=ServiceTypes.REDIS,
duration=end_time - start_time,
error=e,
call_type="redis_sync_ping",
)
)
except Exception:
# If no event loop or hook fails, ignore to keep init non-blocking
pass
if litellm.default_redis_ttl is not None:
super().__init__(default_ttl=int(litellm.default_redis_ttl))
else:
super().__init__() # defaults to 60s
def _get_async_client_cache_key(self) -> str:
"""
Generate a cache key for the async Redis client based on connection parameters.
This ensures different Redis configurations use different cached clients.
"""
# Create a stable representation of redis_kwargs for hashing
# Sort keys to ensure consistent hash regardless of parameter order
sorted_kwargs = sorted(self.redis_kwargs.items())
kwargs_str = json.dumps(sorted_kwargs, sort_keys=True)
kwargs_hash = hashlib.sha256(kwargs_str.encode()).hexdigest()[:16]
return f"async-redis-client-{kwargs_hash}"
def init_async_client(
self,
) -> Union[async_redis_client, async_redis_cluster_client]:
@@ -181,7 +226,8 @@ class RedisCache(BaseCache):
from .._redis import get_redis_async_client, get_redis_connection_pool
cached_client = in_memory_llm_clients_cache.get_cache(key="async-redis-client")
cache_key = self._get_async_client_cache_key()
cached_client = in_memory_llm_clients_cache.get_cache(key=cache_key)
if cached_client is not None:
redis_async_client = cast(
Union[async_redis_client, async_redis_cluster_client], cached_client
@@ -193,7 +239,7 @@ class RedisCache(BaseCache):
connection_pool=self.async_redis_conn_pool, **self.redis_kwargs
)
in_memory_llm_clients_cache.set_cache(
key="async-redis-client", value=redis_async_client
key=cache_key, value=redis_async_client
)
self.redis_async_client = redis_async_client # type: ignore
@@ -18,14 +18,15 @@ def get_model_cost_map(url: str) -> dict:
os.getenv("LITELLM_LOCAL_MODEL_COST_MAP", False)
or os.getenv("LITELLM_LOCAL_MODEL_COST_MAP", False) == "True"
):
import importlib.resources
from importlib.resources import files
import json
with importlib.resources.open_text(
"litellm", "model_prices_and_context_window_backup.json"
) as f:
content = json.load(f)
return content
content = json.loads(
files("litellm")
.joinpath("model_prices_and_context_window_backup.json")
.read_text(encoding="utf-8")
)
return content
try:
response = httpx.get(
@@ -35,11 +36,12 @@ def get_model_cost_map(url: str) -> dict:
content = response.json()
return content
except Exception:
import importlib.resources
from importlib.resources import files
import json
with importlib.resources.open_text(
"litellm", "model_prices_and_context_window_backup.json"
) as f:
content = json.load(f)
return content
content = json.loads(
files("litellm")
.joinpath("model_prices_and_context_window_backup.json")
.read_text(encoding="utf-8")
)
return content
@@ -1572,6 +1572,21 @@ def convert_to_gemini_tool_call_result(
return _part
def _sanitize_anthropic_tool_use_id(tool_use_id: str) -> str:
"""
Sanitize tool_use_id to match Anthropic's required pattern: ^[a-zA-Z0-9_-]+$
Anthropic requires tool_use_id to only contain alphanumeric characters, underscores, and hyphens.
This function replaces any invalid characters with underscores.
"""
# Replace any character that's not alphanumeric, underscore, or hyphen with underscore
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', tool_use_id)
# Ensure it's not empty (fallback to a default if needed)
if not sanitized:
sanitized = "tool_use_id"
return sanitized
def convert_to_anthropic_tool_result(
message: Union[ChatCompletionToolMessage, ChatCompletionFunctionMessage],
) -> AnthropicMessagesToolResultParam:
@@ -1639,18 +1654,22 @@ def convert_to_anthropic_tool_result(
if message["role"] == "tool":
tool_message: ChatCompletionToolMessage = message
tool_call_id: str = tool_message["tool_call_id"]
# Sanitize tool_use_id to match Anthropic's pattern requirement: ^[a-zA-Z0-9_-]+$
sanitized_tool_use_id = _sanitize_anthropic_tool_use_id(tool_call_id)
# We can't determine from openai message format whether it's a successful or
# error call result so default to the successful result template
anthropic_tool_result = AnthropicMessagesToolResultParam(
type="tool_result", tool_use_id=tool_call_id, content=anthropic_content
type="tool_result", tool_use_id=sanitized_tool_use_id, content=anthropic_content
)
if message["role"] == "function":
function_message: ChatCompletionFunctionMessage = message
tool_call_id = function_message.get("tool_call_id") or str(uuid.uuid4())
# Sanitize tool_use_id to match Anthropic's pattern requirement: ^[a-zA-Z0-9_-]+$
sanitized_tool_use_id = _sanitize_anthropic_tool_use_id(tool_call_id)
anthropic_tool_result = AnthropicMessagesToolResultParam(
type="tool_result", tool_use_id=tool_call_id, content=anthropic_content
type="tool_result", tool_use_id=sanitized_tool_use_id, content=anthropic_content
)
if anthropic_tool_result is None:
+1 -1
View File
@@ -769,7 +769,7 @@ class AsyncHTTPHandler:
connector_kwargs["ssl"] = ssl_context
elif ssl_verify is False:
# Priority 2: Explicitly disable SSL verification
connector_kwargs["verify_ssl"] = False
connector_kwargs["ssl"] = False
return connector_kwargs
@@ -240,12 +240,43 @@ class FireworksAIConfig(OpenAIGPTConfig):
return messages
def get_provider_info(self, model: str) -> ProviderSpecificModelInfo:
provider_specific_model_info = ProviderSpecificModelInfo(
supports_function_calling=True,
supports_prompt_caching=True, # https://docs.fireworks.ai/guides/prompt-caching
supports_pdf_input=True, # via document inlining
supports_vision=True, # via document inlining
# Models that support reasoning_effort
reasoning_supported_models = [
"qwen3-8b",
"qwen3-32b",
"qwen3-coder-480b-a35b-instruct",
"deepseek-v3p1",
"deepseek-v3p2",
"glm-4p5",
"glm-4p5-air",
"glm-4p6",
"gpt-oss-120b",
"gpt-oss-20b",
]
# Normalize model name - remove prefix if present
normalized_model = model
if model.startswith("fireworks_ai/"):
normalized_model = model.replace("fireworks_ai/", "")
if normalized_model.startswith("accounts/fireworks/models/"):
normalized_model = normalized_model.replace("accounts/fireworks/models/", "")
# Check if model supports reasoning
supports_reasoning_value = any(
reasoning_model in normalized_model for reasoning_model in reasoning_supported_models
)
provider_specific_model_info: ProviderSpecificModelInfo = {
"supports_function_calling": True,
"supports_prompt_caching": True, # https://docs.fireworks.ai/guides/prompt-caching
"supports_pdf_input": True, # via document inlining
"supports_vision": True, # via document inlining
}
# Only include supports_reasoning if True
if supports_reasoning_value:
provider_specific_model_info["supports_reasoning"] = True
return provider_specific_model_info
def transform_request(
+23 -5
View File
@@ -6,6 +6,7 @@ Docs: https://cloud.ibm.com/apidocs/watsonx-ai#text-chat
from typing import Dict, List, Optional, Tuple, Union
from litellm import verbose_logger
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.watsonx import (
WatsonXAIEndpoint,
@@ -150,8 +151,13 @@ class IBMWatsonXChatConfig(IBMWatsonXMixin, OpenAIGPTConfig):
else:
hf_model = model
try:
return hf_template_fn(model=hf_model, messages=messages)
result = hf_template_fn(model=hf_model, messages=messages)
# Return result if it's truthy (not None and not empty string)
# The caller will handle None/empty by falling back to default
if result:
return result
except Exception:
# Silently fall through to return None - caller will handle fallback
pass
elif WatsonXModelPattern.LLAMA3_INSTRUCT.value in model:
return custom_prompt(
@@ -204,11 +210,23 @@ class IBMWatsonXChatConfig(IBMWatsonXMixin, OpenAIGPTConfig):
try:
# Use sync if cached, async if not
if hf_model in litellm.known_tokenizer_config:
return hf_chat_template(model=hf_model, messages=messages)
result = hf_chat_template(model=hf_model, messages=messages)
else:
return await ahf_chat_template(model=hf_model, messages=messages)
except Exception:
pass
result = await ahf_chat_template(model=hf_model, messages=messages)
# Return result if it's truthy (not None and not empty string)
# The caller (_aconvert_watsonx_messages_core) will handle None/empty by falling back to default
if result:
return result
except Exception as e:
# Log the exception for debugging but don't raise it
# The caller will fall back to default prompt factory
try:
verbose_logger.debug(
f"Failed to apply HuggingFace template for model {hf_model}: {e}"
)
except Exception:
# If logging fails, silently continue - don't break the flow
pass
elif WatsonXModelPattern.LLAMA3_INSTRUCT.value in model:
return custom_prompt(
role_dict={
@@ -15366,7 +15366,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15379,7 +15379,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15392,7 +15392,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions"
"/v1/chat/completions"
],
"supports_vision": true
},
@@ -15403,7 +15403,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15416,7 +15416,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15573,8 +15573,8 @@
"max_tokens": 128000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions",
"/responses"
"/v1/chat/completions",
"/v1/responses"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15599,8 +15599,8 @@
"max_tokens": 64000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions",
"/responses"
"/v1/chat/completions",
"/v1/responses"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15614,7 +15614,7 @@
"max_tokens": 128000,
"mode": "responses",
"supported_endpoints": [
"/responses"
"/v1/responses"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15628,8 +15628,8 @@
"max_tokens": 64000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions",
"/responses"
"/v1/chat/completions",
"/v1/responses"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -16831,7 +16831,8 @@
"supported_endpoints": [
"/v1/images/generations"
],
"supports_vision": true
"supports_vision": true,
"supports_pdf_input": true
},
"gpt-image-1.5-2025-12-16": {
"cache_read_input_image_token_cost": 2e-06,
@@ -16845,7 +16846,8 @@
"supported_endpoints": [
"/v1/images/generations"
],
"supports_vision": true
"supports_vision": true,
"supports_pdf_input": true
},
"gpt-5": {
"cache_read_input_token_cost": 1.25e-07,
@@ -22386,7 +22388,7 @@
"input_cost_per_token": 0,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": null,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 0,
@@ -22428,7 +22430,7 @@
"input_cost_per_token": 1.5e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": null,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 1.5e-07,
@@ -22456,7 +22458,7 @@
"input_cost_per_token": 5e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": null,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 1.5e-06,
@@ -31070,7 +31072,8 @@
"input_cost_per_token": 9e-07,
"output_cost_per_token": 9e-07,
"litellm_provider": "fireworks_ai",
"mode": "chat"
"mode": "chat",
"supports_reasoning": true
},
"fireworks_ai/accounts/fireworks/models/qwen3-4b": {
"max_tokens": 40960,
@@ -2434,6 +2434,27 @@ def validate_membership(
): # allow team keys to check their info
return
# Handle case where user_id is None (e.g., team key accessing different team)
if user_api_key_dict.user_id is None:
if user_api_key_dict.team_id is not None:
raise HTTPException(
status_code=403,
detail={
"error": "Team key for team={} not authorized to access this team={}".format(
user_api_key_dict.team_id, team_table.team_id
)
},
)
else:
raise HTTPException(
status_code=403,
detail={
"error": "API key not authorized to access this team={}. No user_id or team_id associated with this key.".format(
team_table.team_id
)
},
)
if user_api_key_dict.user_id not in [
m.user_id for m in team_table.members_with_roles
]:
+25 -21
View File
@@ -15371,7 +15371,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15384,7 +15384,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15397,7 +15397,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions"
"/v1/chat/completions"
],
"supports_vision": true
},
@@ -15408,7 +15408,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15421,7 +15421,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15578,8 +15578,8 @@
"max_tokens": 128000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions",
"/responses"
"/v1/chat/completions",
"/v1/responses"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15604,8 +15604,8 @@
"max_tokens": 64000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions",
"/responses"
"/v1/chat/completions",
"/v1/responses"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15619,7 +15619,7 @@
"max_tokens": 128000,
"mode": "responses",
"supported_endpoints": [
"/responses"
"/v1/responses"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -15633,8 +15633,8 @@
"max_tokens": 64000,
"mode": "chat",
"supported_endpoints": [
"/chat/completions",
"/responses"
"/v1/chat/completions",
"/v1/responses"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@@ -16836,7 +16836,8 @@
"supported_endpoints": [
"/v1/images/generations"
],
"supports_vision": true
"supports_vision": true,
"supports_pdf_input": true
},
"gpt-image-1.5-2025-12-16": {
"cache_read_input_image_token_cost": 2e-06,
@@ -16850,7 +16851,8 @@
"supported_endpoints": [
"/v1/images/generations"
],
"supports_vision": true
"supports_vision": true,
"supports_pdf_input": true
},
"gpt-5": {
"cache_read_input_token_cost": 1.25e-07,
@@ -22391,7 +22393,7 @@
"input_cost_per_token": 0,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": null,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 0,
@@ -22419,7 +22421,7 @@
"input_cost_per_token": 1e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 131072,
"max_output_tokens": null,
"max_output_tokens": 131072,
"max_tokens": 131072,
"mode": "chat",
"output_cost_per_token": 1e-07,
@@ -22433,7 +22435,7 @@
"input_cost_per_token": 1.5e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": null,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 1.5e-07,
@@ -22447,7 +22449,7 @@
"input_cost_per_token": 2e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": null,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 2e-07,
@@ -22461,7 +22463,7 @@
"input_cost_per_token": 5e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 262144,
"max_output_tokens": null,
"max_output_tokens": 262144,
"max_tokens": 262144,
"mode": "chat",
"output_cost_per_token": 1.5e-06,
@@ -29374,7 +29376,8 @@
"input_cost_per_token": 4.5e-07,
"output_cost_per_token": 1.8e-06,
"litellm_provider": "fireworks_ai",
"mode": "chat"
"mode": "chat",
"supports_reasoning": true
},
"fireworks_ai/accounts/fireworks/models/flux-kontext-pro": {
"max_tokens": 4096,
@@ -31076,7 +31079,8 @@
"input_cost_per_token": 9e-07,
"output_cost_per_token": 9e-07,
"litellm_provider": "fireworks_ai",
"mode": "chat"
"mode": "chat",
"supports_reasoning": true
},
"fireworks_ai/accounts/fireworks/models/qwen3-4b": {
"max_tokens": 40960,
+1 -1
View File
@@ -31,7 +31,7 @@ click = "*"
jinja2 = "^3.1.2"
aiohttp = ">=3.10"
pydantic = "^2.5.0"
jsonschema = "^4.22.0"
jsonschema = ">=4.23.0,<5.0.0"
numpydoc = {version = "*", optional = true} # used in utils.py
uvicorn = {version = "^0.31.1", optional = true}
+2 -1
View File
@@ -60,9 +60,10 @@ aiohttp==3.12.14 # for network calls
aioboto3==13.4.0 # for async sagemaker calls
tenacity==8.5.0 # for retrying requests, when litellm.num_retries set
pydantic>=2.11,<3 # proxy + openai req. + mcp
jsonschema==4.22.0 # validating json schema
jsonschema>=4.23.0,<5.0.0 # validating json schema - aligned with openapi-core + mcp
websockets==13.1.0 # for realtime API
soundfile==0.12.1 # for audio file processing
openapi-core==0.21.0 # for OpenAPI compliance tests
########################
# LITELLM ENTERPRISE DEPENDENCIES
@@ -153,7 +153,7 @@ async def test_litellm_anthropic_prompt_caching_tools():
},
}
],
"max_tokens": 4096,
"max_tokens": 64000,
"model": "claude-3-7-sonnet-20250219",
}
@@ -684,7 +684,7 @@ async def test_litellm_anthropic_prompt_caching_system():
],
}
],
"max_tokens": 4096,
"max_tokens": 64000,
"model": "claude-3-7-sonnet-20250219",
}
+1 -1
View File
@@ -285,7 +285,7 @@ async def test_async_ollama_ssl_verify(stream):
# create aiohttp transport with ssl_verify=False
import aiohttp
aiohttp_session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(verify_ssl=False))
aiohttp_session = aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False))
print("aiohttp_session ssl=", aiohttp_session.connector._ssl)
assert litellm_created_session.connector._ssl is False
@@ -15,26 +15,38 @@ from unittest.mock import MagicMock, patch
import httpx
import pytest
from openapi_core import OpenAPI
from openapi_core.testing.mock import MockRequest, MockResponse
OPENAPI_SPEC_URL = "https://ai.google.dev/static/api/interactions.openapi.json"
def _load_openapi_spec_dict() -> Dict[str, Any]:
"""
Load the OpenAPI spec JSON.
In CI or offline environments, network access may not be available.
In that case, gracefully skip these tests instead of erroring.
"""
try:
response = httpx.get(OPENAPI_SPEC_URL, timeout=5.0)
response.raise_for_status()
return response.json()
except Exception as e: # pragma: no cover - defensive, env-dependent
pytest.skip(
f"Skipping Google Interactions OpenAPI compliance tests - "
f"unable to load spec from {OPENAPI_SPEC_URL}: {e}"
)
@pytest.fixture(scope="module")
def openapi_spec():
"""Load the OpenAPI spec."""
response = httpx.get(OPENAPI_SPEC_URL)
response.raise_for_status()
spec_dict = response.json()
return OpenAPI.from_dict(spec_dict)
@pytest.fixture(scope="module")
def spec_dict():
def spec_dict() -> Dict[str, Any]:
"""Load raw spec dict for manual validation."""
response = httpx.get(OPENAPI_SPEC_URL)
response.raise_for_status()
return response.json()
return _load_openapi_spec_dict()
@pytest.fixture(scope="module")
def openapi_spec(spec_dict: Dict[str, Any]) -> OpenAPI:
"""Load the OpenAPI spec as an OpenAPI object."""
return OpenAPI.from_dict(spec_dict)
class TestRequestCompliance:
@@ -128,7 +128,7 @@ async def test_ssl_verification_with_aiohttp_transport():
assert isinstance(transport_connector, TCPConnector)
aiohttp_session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(verify_ssl=False)
connector=aiohttp.TCPConnector(ssl=False)
)
aiohttp_connector = aiohttp_session.connector
assert isinstance(aiohttp_connector, aiohttp.TCPConnector)