Merge upstream/litellm_internal_staging into add-telnyx-v2

This commit is contained in:
gbattistel 2026-04-29 09:58:32 -04:00
commit d764b2eb1d
38 changed files with 690 additions and 112 deletions

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v1.83.0-stable) — branch will be named release/<tag>"
description: "Release tag (e.g. 1.84.0, 1.84.0rc1, 1.84.0.dev42, 1.84.0.post1; legacy v1.83.10-stable still accepted) — branch will be named release/<tag>"
required: true
type: string
commit_hash:
@ -14,7 +14,7 @@ on:
workflow_call:
inputs:
tag:
description: "Release tag"
description: "Release tag (e.g. 1.84.0, 1.84.0rc1, 1.84.0.dev42, 1.84.0.post1; legacy v1.83.10-stable still accepted)"
required: true
type: string
commit_hash:
@ -40,8 +40,8 @@ jobs:
echo "::error::commit_hash must be a full 40-character commit SHA"
exit 1
fi
if ! echo "${TAG}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::tag must start with vX.Y.Z"
if ! echo "${TAG}" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::tag must start with X.Y.Z (optional leading v), e.g. 1.84.0, 1.84.0rc1, 1.84.0.dev42, or v1.83.10-stable"
exit 1
fi

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g. v1.83.0-stable)"
description: "Release tag (e.g. 1.84.0, 1.84.0rc1, 1.84.0.dev42, 1.84.0.post1; legacy v1.83.10-stable still accepted)"
required: true
type: string
commit_hash:
@ -30,8 +30,8 @@ jobs:
echo "::error::commit_hash must be a full 40-character commit SHA"
exit 1
fi
if ! echo "${TAG}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::tag must start with vX.Y.Z"
if ! echo "${TAG}" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::tag must start with X.Y.Z (optional leading v), e.g. 1.84.0, 1.84.0rc1, 1.84.0.dev42, or v1.83.10-stable"
exit 1
fi
@ -45,6 +45,11 @@ jobs:
const tag = process.env.TAG;
const commitHash = process.env.COMMIT_HASH;
// Mark RC / dev / nightly / alpha / beta tags as GitHub pre-releases.
// PEP 440 post-releases (e.g. `1.84.0.post1`) and legacy `-stable[.patch.N]`
// are stable maintenance releases, not pre-releases.
const isPrerelease = /(?:rc|nightly|alpha|beta|\.dev)/i.test(tag);
const cosignSection = [
`## Verify Docker Image Signature`,
``,
@ -89,7 +94,7 @@ jobs:
target_commitish: commitHash,
name: tag,
owner: context.repo.owner,
prerelease: false,
prerelease: isPrerelease,
repo: context.repo.repo,
tag_name: tag,
});

View file

@ -348,6 +348,7 @@ def get_llm_provider( # noqa: PLR0915
or "ft:gpt-3.5-turbo" in model
or "ft:gpt-4" in model # catches ft:gpt-4-0613, ft:gpt-4o
or model in litellm.openai_image_generation_models
or model.startswith("gpt-image")
or model in litellm.openai_video_generation_models
):
custom_llm_provider = "openai"

View file

@ -1467,6 +1467,8 @@ class Logging(LiteLLMLoggingBaseClass):
LiteLLMRealtimeStreamLoggingObject,
OpenAIModerationResponse,
"SearchResponse",
dict,
list,
],
cache_hit: Optional[bool] = None,
litellm_model_name: Optional[str] = None,
@ -1744,6 +1746,7 @@ class Logging(LiteLLMLoggingBaseClass):
start_time,
end_time,
):
"""Resolve hidden params, compute response cost, and emit the standard logging payload."""
hidden_params = getattr(logging_result, "_hidden_params", {})
if hidden_params:
if self.model_call_details.get("litellm_params") is not None:
@ -1877,24 +1880,12 @@ class Logging(LiteLLMLoggingBaseClass):
):
if self._is_recognized_call_type_for_logging(
logging_result=logging_result
):
) or isinstance(logging_result, (dict, list)):
self._process_hidden_params_and_response_cost(
logging_result=logging_result,
start_time=start_time,
end_time=end_time,
)
elif isinstance(result, dict) or isinstance(result, list):
self.model_call_details["standard_logging_object"] = (
self._build_standard_logging_payload(
result, start_time, end_time
)
)
if (
standard_logging_payload := self.model_call_details.get(
"standard_logging_object"
)
) is not None:
emit_standard_logging_payload(standard_logging_payload)
elif standard_logging_object is not None:
self.model_call_details["standard_logging_object"] = (
standard_logging_object

View file

@ -982,9 +982,9 @@ class CostCalculatorUtils:
image_response=completion_response,
)
elif custom_llm_provider == litellm.LlmProviders.OPENAI.value:
# Check if this is a gpt-image model (token-based pricing)
# gpt-image models use token-based pricing.
model_lower = model.lower()
if "gpt-image-1" in model_lower:
if "gpt-image" in model_lower:
from litellm.llms.openai.image_generation.cost_calculator import (
cost_calculator as openai_gpt_image_cost_calculator,
)
@ -1004,9 +1004,9 @@ class CostCalculatorUtils:
optional_params=optional_params,
)
elif custom_llm_provider == litellm.LlmProviders.AZURE.value:
# Check if this is a gpt-image model (token-based pricing)
# gpt-image models use token-based pricing.
model_lower = model.lower()
if "gpt-image-1" in model_lower:
if "gpt-image" in model_lower:
from litellm.llms.openai.image_generation.cost_calculator import (
cost_calculator as openai_gpt_image_cost_calculator,
)

View file

@ -24,6 +24,6 @@ def get_azure_image_generation_config(model: str) -> BaseImageGenerationConfig:
return AzureDallE3ImageGenerationConfig()
else:
verbose_logger.debug(
f"Using AzureGPTImageGenerationConfig for model: {model}. This follows the gpt-image-1 model format."
f"Using AzureGPTImageGenerationConfig for model: {model}. This follows the gpt-image model format."
)
return AzureGPTImageGenerationConfig()

View file

@ -3,7 +3,7 @@ from litellm.llms.openai.image_generation import GPTImageGenerationConfig
class AzureGPTImageGenerationConfig(GPTImageGenerationConfig):
"""
Azure gpt-image-1 image generation config
Azure gpt-image image generation config
"""
pass

View file

@ -1,5 +1,5 @@
"""
Cost calculator for OpenAI image generation models (gpt-image-1, gpt-image-1-mini)
Cost calculator for OpenAI image generation models (gpt-image family)
These models use token-based pricing instead of pixel-based pricing like DALL-E.
"""
@ -17,13 +17,13 @@ def cost_calculator(
custom_llm_provider: Optional[str] = None,
) -> float:
"""
Calculate cost for OpenAI gpt-image-1 and gpt-image-1-mini models.
Calculate cost for OpenAI gpt-image models.
Uses the same usage format as Responses API, so we reuse the helper
to transform to chat completion format and use generic_cost_per_token.
Args:
model: The model name (e.g., "gpt-image-1", "gpt-image-1-mini")
model: The model name (e.g., "gpt-image-1", "gpt-image-2")
image_response: The ImageResponse containing usage data
custom_llm_provider: Optional provider name

View file

@ -15,7 +15,7 @@ if TYPE_CHECKING:
class GPTImageGenerationConfig(BaseImageGenerationConfig):
"""
OpenAI gpt-image-1 image generation config
OpenAI gpt-image image generation config
"""
def get_supported_openai_params(

View file

@ -102,6 +102,11 @@
"max_completion_tokens": "max_tokens"
}
},
"aihubmix": {
"base_url": "https://aihubmix.com/v1",
"api_key_env": "AIHUBMIX_API_KEY",
"api_base_env": "AIHUBMIX_API_BASE"
},
"telnyx": {
"base_url": "https://api.telnyx.com/v2/ai",
"api_key_env": "TELNYX_API_KEY"

View file

@ -5103,6 +5103,38 @@
"/v1/images/edits"
]
},
"azure/gpt-image-2": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_image_token": 8e-06,
"litellm_provider": "azure",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"output_cost_per_image_token": 3e-05,
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
],
"supports_vision": true,
"supports_pdf_input": true
},
"azure/gpt-image-2-2026-04-21": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_image_token": 8e-06,
"litellm_provider": "azure",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"output_cost_per_image_token": 3e-05,
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
],
"supports_vision": true,
"supports_pdf_input": true
},
"azure/low/1024-x-1024/gpt-image-1-mini": {
"input_cost_per_pixel": 2.0751953125e-09,
"litellm_provider": "azure",
@ -19083,6 +19115,38 @@
"supports_vision": true,
"supports_pdf_input": true
},
"gpt-image-2": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"input_cost_per_image_token": 8e-06,
"output_cost_per_image_token": 3e-05,
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
],
"supports_vision": true,
"supports_pdf_input": true
},
"gpt-image-2-2026-04-21": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"input_cost_per_image_token": 8e-06,
"output_cost_per_image_token": 3e-05,
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
],
"supports_vision": true,
"supports_pdf_input": true
},
"low/1024-x-1024/gpt-image-1.5": {
"input_cost_per_image": 0.009,
"litellm_provider": "openai",

View file

@ -497,14 +497,18 @@ from litellm.proxy.utils import (
_get_redoc_url,
_is_projected_spend_over_limit,
_is_valid_team_configs,
get_config_param,
get_custom_url,
get_error_message_str,
get_server_root_path,
handle_exception_on_proxy,
hash_password,
hash_token,
invalidate_config_param,
litellm_config_cache,
migrate_passwords_to_scrypt_async,
model_dump_with_preserved_fields,
prefetch_config_params,
update_spend,
)
from litellm.proxy.vector_store_endpoints.endpoints import router as vector_store_router
@ -2929,8 +2933,13 @@ class ProxyConfig:
## INIT PROXY REDIS USAGE CLIENT ##
redis_usage_cache = litellm.cache.cache
spend_counter_cache.redis_cache = redis_usage_cache
litellm_config_cache.redis_cache = redis_usage_cache
# Note: PKCE verifier storage uses redis_usage_cache directly (not
# user_api_key_cache) to avoid routing all API-key lookups through Redis.
elif litellm_config_cache.redis_cache is None:
verbose_proxy_logger.info(
"litellm_config_cache: no Redis configured; cluster-wide cache sharing disabled."
)
def switch_on_llm_response_caching(self):
"""
@ -4846,10 +4855,7 @@ class ProxyConfig:
"environment_variables",
]
for k in keys:
response = prisma_client.get_generic_data(
key="param_name", value=k, table_name="config"
)
_tasks.append(response)
_tasks.append(get_config_param(prisma_client, k))
responses = await asyncio.gather(*_tasks)
for response in responses:
@ -4931,6 +4937,19 @@ class ProxyConfig:
global llm_router, llm_model_list, master_key, general_settings
try:
# warm the config cache so the per-param reads below all hit
await prefetch_config_params(
prisma_client,
[
"general_settings",
"router_settings",
"litellm_settings",
"environment_variables",
"model_cost_map_reload_config",
"anthropic_beta_headers_reload_config",
],
)
# Only load models from DB if "models" is in supported_db_objects (or if supported_db_objects is not set)
if self._should_load_db_object(object_type="models"):
new_models = await self._get_models_from_db(prisma_client=prisma_client)
@ -4940,8 +4959,8 @@ class ProxyConfig:
new_models=new_models, proxy_logging_obj=proxy_logging_obj
)
db_general_settings = await prisma_client.db.litellm_config.find_first(
where={"param_name": "general_settings"}
db_general_settings = await get_config_param(
prisma_client, "general_settings"
)
# update general settings
@ -5034,10 +5053,7 @@ class ProxyConfig:
from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook
try:
# Load litellm_settings from DB
config_record = await prisma_client.db.litellm_config.find_unique(
where={"param_name": "litellm_settings"}
)
config_record = await get_config_param(prisma_client, "litellm_settings")
if config_record is None or config_record.param_value is None:
return
@ -5192,8 +5208,8 @@ class ProxyConfig:
"""
try:
# Get model cost map reload configuration from database
config_record = await prisma_client.db.litellm_config.find_unique(
where={"param_name": "model_cost_map_reload_config"}
config_record = await get_config_param(
prisma_client, "model_cost_map_reload_config"
)
if config_record is None or config_record.param_value is None:
@ -5288,6 +5304,7 @@ class ProxyConfig:
},
},
)
await invalidate_config_param("model_cost_map_reload_config")
verbose_proxy_logger.info(
f"Model cost map reloaded successfully. Models count: {len(new_model_cost_map) if new_model_cost_map else 0}"
@ -5307,8 +5324,8 @@ class ProxyConfig:
"""
try:
# Get anthropic beta headers reload configuration from database
config_record = await prisma_client.db.litellm_config.find_unique(
where={"param_name": "anthropic_beta_headers_reload_config"}
config_record = await get_config_param(
prisma_client, "anthropic_beta_headers_reload_config"
)
if config_record is None or config_record.param_value is None:
@ -5396,6 +5413,7 @@ class ProxyConfig:
},
},
)
await invalidate_config_param("anthropic_beta_headers_reload_config")
# Count providers in config
provider_count = sum(
@ -12674,6 +12692,7 @@ async def update_config( # noqa: PLR0915
"update": {"param_value": v},
},
)
await invalidate_config_param(k)
### OLD LOGIC [TODO] MOVE TO DB ###
@ -12861,6 +12880,7 @@ async def update_config_general_settings(
"update": {"param_value": json.dumps(general_settings)}, # type: ignore
},
)
await invalidate_config_param("general_settings")
return response
@ -13144,6 +13164,7 @@ async def delete_config_general_settings(
"update": {"param_value": json.dumps(general_settings)}, # type: ignore
},
)
await invalidate_config_param("general_settings")
return response
@ -13509,6 +13530,7 @@ async def reload_model_cost_map(
},
},
)
await invalidate_config_param("model_cost_map_reload_config")
models_count = len(new_model_cost_map) if new_model_cost_map else 0
verbose_proxy_logger.info(
@ -13578,6 +13600,7 @@ async def schedule_model_cost_map_reload(
},
},
)
await invalidate_config_param("model_cost_map_reload_config")
verbose_proxy_logger.info(
f"Model cost map reload scheduled for every {hours} hours"
@ -13631,6 +13654,7 @@ async def cancel_model_cost_map_reload(
await prisma_client.db.litellm_config.delete(
where={"param_name": "model_cost_map_reload_config"}
)
await invalidate_config_param("model_cost_map_reload_config")
verbose_proxy_logger.info("Model cost map reload schedule cancelled")
@ -13861,6 +13885,7 @@ async def reload_anthropic_beta_headers(
},
},
)
await invalidate_config_param("anthropic_beta_headers_reload_config")
provider_count = sum(
1 for k in new_config.keys() if k not in ["provider_aliases", "description"]
@ -13934,6 +13959,7 @@ async def schedule_anthropic_beta_headers_reload(
},
},
)
await invalidate_config_param("anthropic_beta_headers_reload_config")
verbose_proxy_logger.info(
f"Anthropic beta headers reload scheduled for every {hours} hours"
@ -13987,6 +14013,7 @@ async def cancel_anthropic_beta_headers_reload(
await prisma_client.db.litellm_config.delete(
where={"param_name": "anthropic_beta_headers_reload_config"}
)
await invalidate_config_param("anthropic_beta_headers_reload_config")
verbose_proxy_logger.info("Anthropic beta headers reload schedule cancelled")

View file

@ -2442,6 +2442,92 @@ async def _lookup_deprecated_key(
return None
# DualCache for LiteLLM_Config param_name reads.
# Redis layer is attached in proxy_server._init_cache.
LITELLM_CONFIG_CACHE_TTL_SECONDS: int = int(
os.environ.get("LITELLM_CONFIG_PARAM_CACHE_TTL_SECONDS", "60")
)
_CONFIG_CACHE_MISS: str = "__litellm_config_param_miss__"
litellm_config_cache: DualCache = DualCache(
default_in_memory_ttl=LITELLM_CONFIG_CACHE_TTL_SECONDS,
default_redis_ttl=LITELLM_CONFIG_CACHE_TTL_SECONDS,
)
class _ConfigRow:
"""Mimics the Prisma litellm_config row shape for cached entries."""
__slots__ = ("param_name", "param_value")
def __init__(self, param_name: str, param_value: Any) -> None:
self.param_name = param_name
self.param_value = param_value
def _config_cache_key(param_name: str) -> str:
return f"litellm_config:param:{param_name}"
def _pack_config_row(row: Any) -> Dict[str, Any]:
return {"param_name": row.param_name, "param_value": row.param_value}
def _unpack_config_row(cached: Any) -> Optional[_ConfigRow]:
if cached is None or cached == _CONFIG_CACHE_MISS:
return None
if isinstance(cached, dict):
return _ConfigRow(cached["param_name"], cached["param_value"])
return None
async def get_config_param(prisma_client: Any, param_name: str) -> Optional[Any]:
"""Cached read of a LiteLLM_Config row; returns row, _ConfigRow shim, or None."""
cache_key = _config_cache_key(param_name)
cached = await litellm_config_cache.async_get_cache(cache_key)
if cached is not None:
return _unpack_config_row(cached)
row = await prisma_client.get_generic_data(
key="param_name", value=param_name, table_name="config"
)
cache_value: Any = _pack_config_row(row) if row is not None else _CONFIG_CACHE_MISS
await litellm_config_cache.async_set_cache(
cache_key, cache_value, ttl=LITELLM_CONFIG_CACHE_TTL_SECONDS
)
return row
async def invalidate_config_param(param_name: str) -> None:
"""Evict from both cache layers; call after every LiteLLM_Config write."""
await litellm_config_cache.async_delete_cache(_config_cache_key(param_name))
async def prefetch_config_params(prisma_client: Any, param_names: List[str]) -> None:
"""Batch-load LiteLLM_Config rows into the cache with one find_many."""
if not param_names:
return
try:
rows = await prisma_client.db.litellm_config.find_many(
where={"param_name": {"in": param_names}} # type: ignore
)
except Exception as e:
verbose_proxy_logger.debug(
"prefetch_config_params failed, falling through to per-param queries: %s",
e,
)
return
by_name = {row.param_name: row for row in rows}
for name in param_names:
row = by_name.get(name)
cache_value: Any = (
_pack_config_row(row) if row is not None else _CONFIG_CACHE_MISS
)
await litellm_config_cache.async_set_cache(
_config_cache_key(name), cache_value, ttl=LITELLM_CONFIG_CACHE_TTL_SECONDS
)
class PrismaClient:
spend_log_transactions: List = []
_spend_log_transactions_lock = asyncio.Lock()
@ -3310,6 +3396,9 @@ class PrismaClient:
tasks.append(updated_table_row)
await asyncio.gather(*tasks)
# invalidate cache so other pods see writes from save_config
for k in data.keys():
await invalidate_config_param(k)
verbose_proxy_logger.info("Data Inserted into Config Table")
elif table_name == "spend":
db_data = self.jsonify_object(data=data)

View file

@ -6526,6 +6526,7 @@ def validate_environment( # noqa: PLR0915
or model in litellm.open_ai_text_completion_models
or model in litellm.open_ai_embedding_models
or model in litellm.openai_image_generation_models
or model.startswith("gpt-image")
):
if "OPENAI_API_KEY" in os.environ:
keys_in_environment = True

View file

@ -5117,6 +5117,38 @@
"/v1/images/edits"
]
},
"azure/gpt-image-2": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_image_token": 8e-06,
"litellm_provider": "azure",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"output_cost_per_image_token": 3e-05,
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
],
"supports_vision": true,
"supports_pdf_input": true
},
"azure/gpt-image-2-2026-04-21": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_image_token": 8e-06,
"litellm_provider": "azure",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"output_cost_per_image_token": 3e-05,
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
],
"supports_vision": true,
"supports_pdf_input": true
},
"azure/low/1024-x-1024/gpt-image-1-mini": {
"input_cost_per_pixel": 2.0751953125e-09,
"litellm_provider": "azure",
@ -19097,6 +19129,38 @@
"supports_vision": true,
"supports_pdf_input": true
},
"gpt-image-2": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"input_cost_per_image_token": 8e-06,
"output_cost_per_image_token": 3e-05,
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
],
"supports_vision": true,
"supports_pdf_input": true
},
"gpt-image-2-2026-04-21": {
"cache_read_input_image_token_cost": 2e-06,
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_token": 1e-05,
"input_cost_per_image_token": 8e-06,
"output_cost_per_image_token": 3e-05,
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
],
"supports_vision": true,
"supports_pdf_input": true
},
"low/1024-x-1024/gpt-image-1.5": {
"input_cost_per_image": 0.009,
"litellm_provider": "openai",

View file

@ -193,6 +193,23 @@
"a2a": false
}
},
"aihubmix": {
"display_name": "AIHubMix (`aihubmix`)",
"url": "https://docs.litellm.ai/docs/providers/aihubmix",
"endpoints": {
"chat_completions": true,
"messages": true,
"responses": true,
"embeddings": true,
"image_generations": true,
"audio_transcriptions": true,
"audio_speech": true,
"moderations": true,
"batches": false,
"rerank": true,
"a2a": false
}
},
"assemblyai": {
"display_name": "AssemblyAI (`assemblyai`)",
"url": "https://docs.litellm.ai/docs/pass_through/assembly_ai",

View file

@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.83.14"
version = "1.84.0"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.14"
@ -236,7 +236,7 @@ source-exclude = [
profile = "black"
[tool.commitizen]
version = "1.83.14"
version = "1.84.0"
version_files = [
"pyproject.toml:^version",
]

View file

@ -314,11 +314,11 @@ def test_update_litellm_params_for_health_check():
# Issue #15807: Fixes health checks sending "region/model" as model ID to AWS
model_info = {}
litellm_params = {
"model": "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0",
"model": "bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0",
"api_key": "fake_key",
}
updated_params = _update_litellm_params_for_health_check(model_info, litellm_params)
assert updated_params["model"] == "anthropic.claude-3-7-sonnet-20250219-v1:0"
assert updated_params["model"] == "anthropic.claude-sonnet-4-5-20250929-v1:0"
# Test with Bedrock cross-region inference profile - should preserve the inference profile prefix
# AWS requires inference profile IDs like "us.anthropic.claude..." for cross-region routing

View file

@ -2309,11 +2309,11 @@ def test_get_provider_audio_transcription_config():
@pytest.mark.parametrize(
"model, expected_bool",
[
("anthropic.claude-3-7-sonnet-20250219-v1:0", True),
("us.anthropic.claude-3-7-sonnet-20250219-v1:0", True),
("anthropic.claude-sonnet-4-5-20250929-v1:0", True),
("us.anthropic.claude-sonnet-4-5-20250929-v1:0", True),
],
)
def test_claude_3_7_sonnet_supports_pdf_input(model, expected_bool):
def test_claude_sonnet_4_5_supports_pdf_input(model, expected_bool):
from litellm.utils import supports_pdf_input
assert supports_pdf_input(model) == expected_bool

View file

@ -134,7 +134,7 @@ class TestBedrockAnthropicPromptCachingRegression:
if "converse" in model_prefix:
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
optional_params={},
litellm_params={},
@ -162,7 +162,7 @@ class TestBedrockAnthropicPromptCachingRegression:
else:
config = AmazonAnthropicClaudeConfig()
result = config.transform_request(
model="us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
optional_params={},
litellm_params={},
@ -227,7 +227,7 @@ class TestBedrockAnthropicPromptCachingRegression:
if "converse" in model_prefix:
config = AmazonConverseConfig()
result = config._transform_request_helper(
model="us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
system_content_blocks=[],
optional_params={},
messages=messages,
@ -236,7 +236,7 @@ class TestBedrockAnthropicPromptCachingRegression:
else:
config = AmazonAnthropicClaudeConfig()
result = config.transform_request(
model="us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
optional_params={},
litellm_params={},
@ -498,7 +498,7 @@ class TestBedrockAnthropicCombinedRegressions:
if "converse" in model_prefix:
config = AmazonConverseConfig()
result = config._transform_request_helper(
model="us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
system_content_blocks=[],
optional_params={},
messages=messages,
@ -518,7 +518,7 @@ class TestBedrockAnthropicCombinedRegressions:
else:
config = AmazonAnthropicClaudeConfig()
result = config.transform_request(
model="us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
optional_params={},
litellm_params={},

View file

@ -1323,7 +1323,7 @@ def test_base_aws_llm_get_credentials():
def test_bedrock_completion_test_2():
litellm.set_verbose = True
data = {
"model": "bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0",
"model": "bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0",
"messages": [
{
"role": "system",
@ -1630,7 +1630,7 @@ def test_bedrock_completion_test_4(modify_params):
litellm.modify_params = modify_params
data = {
"model": "anthropic.claude-3-7-sonnet-20250219-v1:0",
"model": "anthropic.claude-sonnet-4-5-20250929-v1:0",
"messages": [
{
"role": "user",
@ -2115,7 +2115,7 @@ class TestBedrockConverseAnthropicUnitTests(BaseAnthropicChatTest):
def get_base_completion_call_args_with_thinking(self) -> dict:
return {
"model": "bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
"model": "bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"thinking": {"type": "enabled", "budget_tokens": 16000},
}
@ -2828,7 +2828,7 @@ async def test_bedrock_thinking_in_assistant_message(sync_mode):
client = AsyncHTTPHandler()
params = {
"model": "bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
"model": "bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"messages": [
{
"role": "assistant",
@ -2887,7 +2887,7 @@ async def test_bedrock_stream_thinking_content_openwebui():
```
"""
response = await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[{"role": "user", "content": "Hello who is this?"}],
stream=True,
max_tokens=1080,

View file

@ -580,7 +580,7 @@ def test_litellm_gateway_from_sdk_with_response_cost_in_additional_headers():
def test_litellm_gateway_from_sdk_with_thinking_param():
try:
response = litellm.completion(
model="litellm_proxy/anthropic.claude-3-7-sonnet-20250219-v1:0",
model="litellm_proxy/anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[{"role": "user", "content": "Hello world"}],
api_base="http://0.0.0.0:4000",
api_key="sk-PIp1h0RekR",

View file

@ -1828,7 +1828,7 @@ def test_azure_response_format_param():
"model, provider",
[
("claude-3-7-sonnet-20240620-v1:0", "anthropic"),
("anthropic.claude-3-7-sonnet-20250219-v1:0", "bedrock"),
("anthropic.claude-sonnet-4-5-20250929-v1:0", "bedrock"),
("invoke/anthropic.claude-3-7-sonnet-20240620-v1:0", "bedrock"),
("claude-3-7-sonnet@20250219", "vertex_ai"),
],

View file

@ -159,7 +159,7 @@ def test_aaparallel_function_call(model):
"model",
[
"anthropic/claude-4-sonnet-20250514",
"bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
],
)
@pytest.mark.flaky(retries=3, delay=1)

View file

@ -30,10 +30,9 @@ def test_model_alias_map(caplog):
)
print(response.model)
captured_logs = [rec.levelname for rec in caplog.records]
for log in captured_logs:
assert "ERROR" not in log
for rec in caplog.records:
if rec.levelname == "ERROR" and rec.name.startswith("LiteLLM"):
pytest.fail(f"Unexpected litellm ERROR log: {rec.getMessage()}")
assert "llama-3.1-8b-instant" in response.model
except litellm.ServiceUnavailableError:

View file

@ -96,8 +96,8 @@ class BaseAnthropicMessagesPromptCachingTest(ABC):
Returns the model string to use for tests.
Examples:
- "bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0"
- "bedrock/invoke/anthropic.claude-3-7-sonnet-20250219-v1:0"
- "bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0"
- "bedrock/invoke/anthropic.claude-sonnet-4-5-20250929-v1:0"
"""
pass

View file

@ -31,7 +31,7 @@ class TestBedrockConversePromptCaching(BaseAnthropicMessagesPromptCachingTest):
"""
def get_model(self) -> str:
return "bedrock/converse/us.anthropic.claude-3-7-sonnet-20250219-v1:0"
return "bedrock/converse/us.anthropic.claude-sonnet-4-5-20250929-v1:0"
class TestBedrockInvokePromptCaching(BaseAnthropicMessagesPromptCachingTest):
@ -43,4 +43,4 @@ class TestBedrockInvokePromptCaching(BaseAnthropicMessagesPromptCachingTest):
"""
def get_model(self) -> str:
return "bedrock/invoke/us.anthropic.claude-3-7-sonnet-20250219-v1:0"
return "bedrock/invoke/us.anthropic.claude-sonnet-4-5-20250929-v1:0"

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
{"id": "chatcmpl-fa9be5b7-9487-46ab-86de-6462d578fea1", "created": 1750615148, "model": "arn:aws:bedrock:us-west-2:1234567890123:inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0", "object": "chat.completion", "system_fingerprint": null, "choices": [{"finish_reason": "stop", "index": 0, "message": {"content": "The capital of France is Paris. Paris has been the capital city of France since 987 CE when Hugh Capet, the first king of the Capetian dynasty, made the city his seat of government. Today, Paris is not only the political capital but also the cultural and economic center of France.", "role": "assistant", "tool_calls": null, "function_call": null}}], "usage": {"completion_tokens": 67, "prompt_tokens": 14, "total_tokens": 81, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}
{"id": "chatcmpl-fa9be5b7-9487-46ab-86de-6462d578fea1", "created": 1750615148, "model": "arn:aws:bedrock:us-west-2:1234567890123:inference-profile/us.anthropic.claude-sonnet-4-5-20250929-v1:0", "object": "chat.completion", "system_fingerprint": null, "choices": [{"finish_reason": "stop", "index": 0, "message": {"content": "The capital of France is Paris. Paris has been the capital city of France since 987 CE when Hugh Capet, the first king of the Capetian dynasty, made the city his seat of government. Today, Paris is not only the political capital but also the cultural and economic center of France.", "role": "assistant", "tool_calls": null, "function_call": null}}], "usage": {"completion_tokens": 67, "prompt_tokens": 14, "total_tokens": 81, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}

View file

@ -220,7 +220,7 @@ async def test_anthropic_cache_control_hook_negative_indices():
with patch.object(client, "post", return_value=mock_response) as mock_post:
# Test with multiple messages and negative indices
response = await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[
{
"role": "system",
@ -352,7 +352,7 @@ async def test_anthropic_cache_control_hook_out_of_bounds_logging():
]
await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
cache_control_injection_points=[
{"location": "message", "index": 10}
@ -420,7 +420,7 @@ async def test_anthropic_cache_control_hook_negative_out_of_bounds_logging():
]
await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
cache_control_injection_points=[
{
@ -486,7 +486,7 @@ async def test_anthropic_cache_control_hook_multiple_user_messages():
with patch.object(client, "post", return_value=mock_response) as mock_post:
# Test with multiple user messages and negative indices
response = await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[
{
"role": "user",
@ -586,7 +586,7 @@ async def test_anthropic_cache_control_hook_out_of_bounds(bad_index):
]
await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
cache_control_injection_points=[
{"location": "message", "index": bad_index}
@ -651,7 +651,7 @@ async def test_anthropic_cache_control_hook_single_message(message_list):
client = AsyncHTTPHandler()
with patch.object(client, "post", return_value=mock_response) as mock_post:
await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=message_list,
cache_control_injection_points=[{"location": "message", "index": -1}],
client=client,
@ -691,7 +691,7 @@ async def test_anthropic_cache_control_hook_empty_message_list():
match="bedrock requires at least one non-system message",
):
await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[],
cache_control_injection_points=[
{"location": "message", "index": -1}
@ -742,7 +742,7 @@ async def test_anthropic_cache_control_hook_no_op():
]
await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=messages,
# No cache_control_injection_points parameter
client=client,
@ -799,7 +799,7 @@ async def test_anthropic_cache_control_hook_multiple_content_items_last_only():
client = AsyncHTTPHandler()
with patch.object(client, "post", return_value=mock_response) as mock_post:
response = await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[
{
"role": "user",
@ -874,7 +874,7 @@ async def test_anthropic_cache_control_hook_document_analysis_multiple_pages():
client = AsyncHTTPHandler()
with patch.object(client, "post", return_value=mock_response) as mock_post:
response = await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[
{
"role": "user",
@ -1057,7 +1057,7 @@ async def test_anthropic_cache_control_hook_string_negative_index():
client = AsyncHTTPHandler()
with patch.object(client, "post", return_value=mock_response) as mock_post:
await litellm.acompletion(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
messages=[
{"role": "user", "content": "First message"},
{"role": "assistant", "content": "First response"},

View file

@ -262,7 +262,7 @@ class TestOpenTelemetryProviderInitialization(unittest.TestCase):
class TestOpenTelemetry(unittest.TestCase):
POLL_INTERVAL = 0.05
POLL_TIMEOUT = 2.0
MODEL = "arn:aws:bedrock:us-west-2:1234567890123:inference-profile/us.anthropic.claude-3-7-sonnet-20250219-v1:0"
MODEL = "arn:aws:bedrock:us-west-2:1234567890123:inference-profile/us.anthropic.claude-sonnet-4-5-20250929-v1:0"
HERE = os.path.dirname(__file__)
@patch.dict(os.environ, {}, clear=True)

View file

@ -77,7 +77,7 @@ async def test_anthropic_bedrock_thinking_blocks_with_none_content():
# test _bedrock_converse_messages_pt_async
result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async(
messages=messages,
model="us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
llm_provider="bedrock",
)

View file

@ -2534,3 +2534,141 @@ def test_get_standard_logging_object_payload_includes_litellm_call_id(logging_ob
assert payload is not None
assert payload["litellm_call_id"] == call_id
def _make_dict_logging_obj():
"""Build a Logging instance configured for a non-streaming dict result."""
obj = LitellmLogging(
model="claude-haiku-4-5@20251001",
messages=[{"role": "user", "content": "hi"}],
stream=False,
call_type="acompletion",
litellm_call_id="test-call-id",
start_time=time.time(),
function_id="test-fn",
)
obj.model_call_details = {
"model": "claude-haiku-4-5@20251001",
"custom_llm_provider": "vertex_ai",
"litellm_params": {"metadata": {}},
"response_cost": None,
}
return obj
def test_success_handler_computes_cost_for_dict_response():
"""Non-streaming dict responses run through the cost calculator."""
logging_obj = _make_dict_logging_obj()
expected_cost = 0.42
with (
patch.object(
logging_obj,
"_response_cost_calculator",
return_value=expected_cost,
) as mock_calc,
patch.object(
logging_obj,
"_build_standard_logging_payload",
return_value={"response_cost": expected_cost},
),
patch(
"litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"
),
patch.object(
logging_obj,
"_is_recognized_call_type_for_logging",
return_value=False,
),
patch.object(
logging_obj,
"_transform_usage_objects",
side_effect=lambda result: result,
),
):
logging_obj.success_handler(
result={"id": "msg_1"},
start_time=time.time(),
end_time=time.time(),
)
mock_calc.assert_called_once()
assert logging_obj.model_call_details["response_cost"] == expected_cost
def test_success_handler_preserves_precomputed_cost_for_dict_response():
"""Precomputed response_cost on model_call_details must not be overwritten."""
logging_obj = _make_dict_logging_obj()
precomputed_cost = 1.23
logging_obj.model_call_details["response_cost"] = precomputed_cost
with (
patch.object(
logging_obj,
"_response_cost_calculator",
return_value=9.99,
) as mock_calc,
patch.object(
logging_obj,
"_build_standard_logging_payload",
return_value={"response_cost": precomputed_cost},
),
patch(
"litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"
),
patch.object(
logging_obj,
"_is_recognized_call_type_for_logging",
return_value=False,
),
patch.object(
logging_obj,
"_transform_usage_objects",
side_effect=lambda result: result,
),
):
logging_obj.success_handler(
result={"id": "msg_2"},
start_time=time.time(),
end_time=time.time(),
)
mock_calc.assert_not_called()
assert logging_obj.model_call_details["response_cost"] == precomputed_cost
def test_success_handler_unified_helper_runs_for_typed_results():
"""Recognized typed responses still flow through the unified helper."""
logging_obj = _make_dict_logging_obj()
expected_cost = 0.10
typed_result = MagicMock()
typed_result._hidden_params = {}
with (
patch.object(
logging_obj,
"_response_cost_calculator",
return_value=expected_cost,
) as mock_calc,
patch.object(
logging_obj,
"_build_standard_logging_payload",
return_value={"response_cost": expected_cost},
),
patch(
"litellm.litellm_core_utils.litellm_logging.emit_standard_logging_payload"
),
patch.object(
logging_obj,
"_is_recognized_call_type_for_logging",
return_value=True,
),
patch.object(
logging_obj,
"_transform_usage_objects",
side_effect=lambda result: result,
),
):
logging_obj.success_handler(
result=typed_result,
start_time=time.time(),
end_time=time.time(),
)
mock_calc.assert_called_once()
assert logging_obj.model_call_details["response_cost"] == expected_cost

View file

@ -279,7 +279,7 @@ def test_reasoning_with_forced_tool_choice_switches_to_auto():
}
optional_params = config.map_openai_params(
model="bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0",
non_default_params=non_default_params,
optional_params={},
drop_params=False,
@ -2797,7 +2797,7 @@ def test_thinking_with_max_completion_tokens():
result = config.map_openai_params(
non_default_params=non_default_params_with_max_completion,
optional_params=optional_params,
model="us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
drop_params=False,
)
@ -2819,7 +2819,7 @@ def test_thinking_with_max_completion_tokens():
result = config.map_openai_params(
non_default_params=non_default_params_with_max_tokens,
optional_params=optional_params,
model="us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
drop_params=False,
)
@ -2842,7 +2842,7 @@ def test_thinking_with_max_completion_tokens():
result = config.map_openai_params(
non_default_params=non_default_params_without_max,
optional_params=optional_params,
model="us.anthropic.claude-3-7-sonnet-20250219-v1:0",
model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
drop_params=False,
)
@ -3617,7 +3617,7 @@ class TestBedrockMinThinkingBudgetTokens:
"""Test that thinking.budget_tokens is clamped to the Bedrock minimum (1024)."""
def _map_params(
self, thinking_value, model="anthropic.claude-3-7-sonnet-20250219-v1:0"
self, thinking_value, model="anthropic.claude-sonnet-4-5-20250929-v1:0"
):
"""Helper to call map_openai_params with the given thinking value."""
config = AmazonConverseConfig()
@ -3651,7 +3651,7 @@ class TestBedrockMinThinkingBudgetTokens:
result = config.map_openai_params(
non_default_params={},
optional_params={},
model="anthropic.claude-3-7-sonnet-20250219-v1:0",
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
drop_params=False,
)
assert "thinking" not in result or result.get("thinking") is None

View file

@ -2544,6 +2544,14 @@ class TestPriceDataReloadAPI:
class TestPriceDataReloadIntegration:
"""Integration tests for the complete price data reload feature"""
@pytest.fixture(autouse=True)
def _flush_litellm_config_cache(self):
from litellm.proxy.utils import litellm_config_cache
litellm_config_cache.flush_cache()
yield
litellm_config_cache.flush_cache()
@pytest.fixture
def client_with_auth(self):
"""Create a test client with authentication"""
@ -2601,6 +2609,7 @@ class TestPriceDataReloadIntegration:
def test_distributed_reload_check_function(self):
"""Test the _check_and_reload_model_cost_map function"""
from litellm.proxy.proxy_server import ProxyConfig
from litellm.proxy.utils import litellm_config_cache
proxy_config = ProxyConfig()
@ -2609,14 +2618,19 @@ class TestPriceDataReloadIntegration:
# Test case 1: No config in database
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
# _check_and_reload_model_cost_map routes through get_config_param,
# which calls prisma.get_generic_data on a cache miss.
mock_prisma.get_generic_data = AsyncMock(return_value=None)
# Should return early without reloading
asyncio.run(proxy_config._check_and_reload_model_cost_map(mock_prisma))
# Test case 2: Config with interval but not time to reload
litellm_config_cache.flush_cache()
mock_config = MagicMock()
mock_config.param_value = {"interval_hours": 6, "force_reload": False}
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=mock_config)
mock_prisma.get_generic_data = AsyncMock(return_value=mock_config)
# Mock current time and last reload time
with patch(
@ -2632,8 +2646,10 @@ class TestPriceDataReloadIntegration:
asyncio.run(proxy_config._check_and_reload_model_cost_map(mock_prisma))
# Test case 3: Config with force reload
litellm_config_cache.flush_cache()
mock_config.param_value = {"interval_hours": 6, "force_reload": True}
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=mock_config)
mock_prisma.get_generic_data = AsyncMock(return_value=mock_config)
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
original_model_cost = litellm.model_cost.copy()
@ -2675,6 +2691,8 @@ class TestPriceDataReloadIntegration:
mock_config = MagicMock()
mock_config.param_value = {"interval_hours": 24, "force_reload": True}
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=mock_config)
# _check_and_reload_model_cost_map now reads through get_generic_data.
mock_prisma.get_generic_data = AsyncMock(return_value=mock_config)
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
original_model_cost = litellm.model_cost.copy()
@ -2770,6 +2788,8 @@ class TestPriceDataReloadIntegration:
mock_config = MagicMock()
mock_config.param_value = {"interval_hours": 12, "force_reload": True}
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=mock_config)
# _check_and_reload_anthropic_beta_headers now reads through get_generic_data.
mock_prisma.get_generic_data = AsyncMock(return_value=mock_config)
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
with patch(

View file

@ -29,8 +29,21 @@ from litellm.types.utils import (
)
@pytest.fixture(autouse=True)
def _use_local_model_cost_map(monkeypatch):
original_model_cost = litellm.model_cost
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
litellm.model_cost = litellm.get_model_cost_map(url="")
litellm.get_model_info.cache_clear()
try:
yield
finally:
litellm.model_cost = original_model_cost
litellm.get_model_info.cache_clear()
class TestGPTImageCostCalculator:
"""Test the OpenAI gpt-image-1 cost calculator"""
"""Test the OpenAI gpt-image cost calculator"""
def test_gpt_image_1_cost_with_text_only(self):
"""Test cost calculation with only text input tokens"""
@ -149,6 +162,44 @@ class TestGPTImageCostCalculator:
assert cost == 0.0
def test_gpt_image_2_cost_with_text_and_image_tokens(self):
"""Test cost calculation for gpt-image-2 token pricing"""
from litellm.llms.openai.image_generation.cost_calculator import cost_calculator
usage = Usage(
prompt_tokens=600,
completion_tokens=5000,
total_tokens=5600,
prompt_tokens_details=PromptTokensDetailsWrapper(
text_tokens=100,
image_tokens=500,
),
completion_tokens_details=CompletionTokensDetailsWrapper(
text_tokens=1000,
image_tokens=4000,
),
)
image_response = ImageResponse(
created=1234567890,
data=[ImageObject(url="http://example.com/image.jpg")],
)
image_response.usage = usage
cost = cost_calculator(
model="gpt-image-2",
image_response=image_response,
custom_llm_provider="openai",
)
# GPT Image 2 pricing:
# Text input: 100 * $5/1M = 0.0005
# Image input: 500 * $8/1M = 0.004
# Text output: 1000 * $10/1M = 0.01
# Image output: 4000 * $30/1M = 0.12
expected_cost = 0.0005 + 0.004 + 0.01 + 0.12
assert abs(cost - expected_cost) < 1e-6, f"Expected {expected_cost}, got {cost}"
class TestGPTImageCostRouting:
"""Test that gpt-image models are properly routed to the token-based calculator"""
@ -182,6 +233,33 @@ class TestGPTImageCostRouting:
expected_cost = 0.0005 + 0.2
assert abs(cost - expected_cost) < 1e-6, f"Expected {expected_cost}, got {cost}"
def test_openai_gpt_image_2_routes_to_token_calculator(self):
"""Test that OpenAI gpt-image-2 routes to token-based calculator"""
from litellm.litellm_core_utils.llm_cost_calc.utils import CostCalculatorUtils
usage = Usage(
prompt_tokens=100,
completion_tokens=5000,
total_tokens=5100,
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=100),
completion_tokens_details=CompletionTokensDetailsWrapper(image_tokens=5000),
)
image_response = ImageResponse(
created=1234567890,
data=[ImageObject(url="http://example.com/image.jpg")],
)
image_response.usage = usage
cost = CostCalculatorUtils.route_image_generation_cost_calculator(
model="gpt-image-2",
completion_response=image_response,
custom_llm_provider="openai",
)
expected_cost = 0.0005 + 0.15
assert abs(cost - expected_cost) < 1e-6, f"Expected {expected_cost}, got {cost}"
def test_openai_dalle_routes_to_pixel_calculator(self):
"""Test that OpenAI DALL-E still routes to pixel-based calculator"""
from litellm.litellm_core_utils.llm_cost_calc.utils import CostCalculatorUtils

View file

@ -32,6 +32,19 @@ from litellm.utils import (
# Adds the parent directory to the system path
@pytest.fixture
def local_model_cost_map(monkeypatch):
original_model_cost = litellm.model_cost
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
litellm.model_cost = litellm.get_model_cost_map(url="")
litellm.get_model_info.cache_clear()
try:
yield
finally:
litellm.model_cost = original_model_cost
litellm.get_model_info.cache_clear()
def test_check_provider_match_azure_ai_allows_openai_and_azure():
"""
Test that azure_ai provider can match openai and azure models.
@ -198,6 +211,72 @@ def test_get_optional_params_image_gen_filters_empty_values():
assert optional_params == {}
def test_gpt_image_provider_detection_covers_existing_family():
for image_model in ("gpt-image-1", "gpt-image-1-mini", "gpt-image-1.5"):
model, custom_llm_provider, _, _ = litellm.get_llm_provider(model=image_model)
assert model == image_model
assert custom_llm_provider == "openai"
def test_gpt_image_2_provider_and_model_info(local_model_cost_map):
model, custom_llm_provider, _, _ = litellm.get_llm_provider(model="gpt-image-2")
assert model == "gpt-image-2"
assert custom_llm_provider == "openai"
model_info = litellm.get_model_info(model="gpt-image-2")
assert model_info["litellm_provider"] == "openai"
assert model_info["mode"] == "image_generation"
assert model_info["input_cost_per_token"] == 5e-06
assert model_info["input_cost_per_image_token"] == 8e-06
assert model_info["output_cost_per_token"] == 1e-05
assert model_info["output_cost_per_image_token"] == 3e-05
assert (
"/v1/images/generations"
in litellm.model_cost["gpt-image-2"]["supported_endpoints"]
)
assert (
"/v1/images/edits" in litellm.model_cost["gpt-image-2"]["supported_endpoints"]
)
assert model_info["supports_vision"] is True
assert model_info["supports_pdf_input"] is True
def test_gpt_image_2_snapshot_model_info(local_model_cost_map):
model, custom_llm_provider, _, _ = litellm.get_llm_provider(
model="gpt-image-2-2026-04-21"
)
assert model == "gpt-image-2-2026-04-21"
assert custom_llm_provider == "openai"
model_info = litellm.get_model_info(model="gpt-image-2-2026-04-21")
assert model_info["litellm_provider"] == "openai"
assert model_info["mode"] == "image_generation"
assert model_info["output_cost_per_image_token"] == 3e-05
def test_azure_gpt_image_2_model_info(local_model_cost_map):
model, custom_llm_provider, _, _ = litellm.get_llm_provider(
model="azure/gpt-image-2"
)
assert model == "gpt-image-2"
assert custom_llm_provider == "azure"
model_info = litellm.get_model_info(
model="gpt-image-2", custom_llm_provider="azure"
)
assert model_info["litellm_provider"] == "azure"
assert model_info["mode"] == "image_generation"
assert model_info["input_cost_per_token"] == 5e-06
assert model_info["input_cost_per_image_token"] == 8e-06
assert model_info["output_cost_per_token"] == 1e-05
assert model_info["output_cost_per_image_token"] == 3e-05
def test_all_model_configs():
from litellm.llms.vertex_ai.vertex_ai_partner_models.ai21.transformation import (
VertexAIAi21Config,
@ -1179,7 +1258,7 @@ def test_get_model_info_shows_supports_computer_use():
"model, custom_llm_provider",
[
("gpt-3.5-turbo", "openai"),
("anthropic.claude-3-7-sonnet-20250219-v1:0", "bedrock"),
("anthropic.claude-sonnet-4-5-20250929-v1:0", "bedrock"),
("gemini-2.5-pro", "vertex_ai"),
],
)
@ -1325,7 +1404,7 @@ class TestProxyFunctionCalling:
),
(
"litellm_proxy/bedrock-claude-3-opus",
"bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0",
False,
),
(
@ -1623,7 +1702,7 @@ class TestProxyFunctionCalling:
),
(
"litellm_proxy/bedrock-claude-3-opus",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
False,
"Bedrock Claude 3 Opus via Converse API",
),
@ -1710,7 +1789,7 @@ class TestProxyFunctionCalling:
),
(
"litellm_proxy/staging-claude-opus",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
False,
"Staging Claude Opus",
),
@ -1722,7 +1801,7 @@ class TestProxyFunctionCalling:
),
(
"litellm_proxy/high-performance-claude",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
False,
"High-performance Claude deployment",
),
@ -1860,7 +1939,7 @@ class TestProxyFunctionCalling:
bedrock_models = [
"bedrock/converse/anthropic.claude-3-haiku-20240307-v1:0",
"bedrock/converse/anthropic.claude-3-sonnet-20240229-v1:0",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
]
for model in bedrock_models:
@ -1892,7 +1971,7 @@ class TestProxyFunctionCalling:
),
(
"litellm_proxy/bedrock-claude-3-opus",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
False,
"Bedrock Claude 3 Opus via Converse API",
),
@ -1979,7 +2058,7 @@ class TestProxyFunctionCalling:
),
(
"litellm_proxy/staging-claude-opus",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
False,
"Staging Claude Opus",
),
@ -1991,7 +2070,7 @@ class TestProxyFunctionCalling:
),
(
"litellm_proxy/high-performance-claude",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
False,
"High-performance Claude deployment",
),
@ -2129,7 +2208,7 @@ class TestProxyFunctionCalling:
bedrock_models = [
"bedrock/converse/anthropic.claude-3-haiku-20240307-v1:0",
"bedrock/converse/anthropic.claude-3-sonnet-20240229-v1:0",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
]
for model in bedrock_models:
@ -2161,7 +2240,7 @@ class TestProxyFunctionCalling:
),
(
"litellm_proxy/bedrock-claude-3-opus",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
False,
"Bedrock Claude 3 Opus via Converse API",
),
@ -2248,7 +2327,7 @@ class TestProxyFunctionCalling:
),
(
"litellm_proxy/staging-claude-opus",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
False,
"Staging Claude Opus",
),
@ -2260,7 +2339,7 @@ class TestProxyFunctionCalling:
),
(
"litellm_proxy/high-performance-claude",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
False,
"High-performance Claude deployment",
),
@ -2398,7 +2477,7 @@ class TestProxyFunctionCalling:
bedrock_models = [
"bedrock/converse/anthropic.claude-3-haiku-20240307-v1:0",
"bedrock/converse/anthropic.claude-3-sonnet-20240229-v1:0",
"bedrock/converse/anthropic.claude-3-7-sonnet-20250219-v1:0",
"bedrock/converse/anthropic.claude-sonnet-4-5-20250929-v1:0",
]
for model in bedrock_models:

4
uv.lock generated
View file

@ -9,7 +9,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-04-23T02:32:27.506663Z"
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P3D"
[manifest]
@ -3085,7 +3085,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.83.14"
version = "1.84.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },