From e2932d22c0b6d6e0a01c4346fe304525eed9bf8e Mon Sep 17 00:00:00 2001 From: Nitish Agarwal <1592163+nitishagar@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:06:36 +0530 Subject: [PATCH 01/70] fix(spend-tracking): hash raw api keys before persisting to spend logs (#30670) The Request Logs "Key Hash" column binds to the SpendLogs metadata user_api_key field, which _get_spend_logs_metadata copied verbatim from request metadata without hashing. The top-level api_key column only hashed values starting with sk-. So a non-sk- passthrough provider key, or a Bearer-prefixed key on paths that do not strip it, could be persisted and shown in plaintext. A single helper now redacts both fields at the SpendLogs builder: it strips a Bearer prefix, passes through values already a sha256 hash or hashed-jwt- identifier, and otherwise hashes with hash_token. sk- keys still map to the same canonical hash, so log and spend correlation is unchanged --- .../spend_tracking/spend_tracking_utils.py | 42 ++- .../test_spend_tracking_utils.py | 270 ++++++++++++++++-- 2 files changed, 290 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index aef06a3c668..e8aa2325a18 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -24,6 +24,7 @@ from litellm.litellm_core_utils.core_helpers import ( get_litellm_metadata_from_kwargs, reconstruct_model_name, ) +from litellm.litellm_core_utils.litellm_logging import is_valid_sha256_hash from litellm.litellm_core_utils.safe_json_dumps import safe_dumps, strip_null_bytes from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error @@ -65,6 +66,24 @@ def _is_master_key(api_key: Optional[str], _master_key: Optional[str]) -> bool: return secrets.compare_digest(api_key, _master_key) +_HASHED_JWT_RE = re.compile(r"^hashed-jwt-[a-fA-F0-9]{64}$") + + +def _redact_logged_api_key( + value: str | None, *, already_hashed: bool = False +) -> str | None: + if not isinstance(value, str) or not value: + return None + stripped = re.sub(r"(?i)^bearer ", "", value) + if not stripped: + return None + if already_hashed and is_valid_sha256_hash(stripped): + return stripped + if _HASHED_JWT_RE.match(stripped): + return stripped + return hash_token(stripped) + + def _get_spend_logs_metadata( metadata: Optional[dict], applied_guardrails: Optional[List[str]] = None, @@ -121,6 +140,16 @@ def _get_spend_logs_metadata( key: metadata.get(key) for key in SpendLogsMetadata.__annotations__.keys() } ) + _raw_key = clean_metadata.get("user_api_key") + _trusted_hash = metadata.get("user_api_key_hash") if metadata else None + _already_hashed = ( + isinstance(_trusted_hash, str) + and is_valid_sha256_hash(_trusted_hash) + and _trusted_hash == _raw_key + ) + clean_metadata["user_api_key"] = _redact_logged_api_key( + _raw_key, already_hashed=_already_hashed + ) clean_metadata["applied_guardrails"] = applied_guardrails clean_metadata["batch_models"] = batch_models clean_metadata["mcp_tool_call_metadata"] = mcp_tool_call_metadata @@ -283,10 +312,13 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs "completion_tokens", 0 ) standard_logging_total_tokens = standard_logging_payload.get("total_tokens", 0) - if api_key is not None and isinstance(api_key, str): - if api_key.startswith("sk-"): - # hash the api_key - api_key = hash_token(api_key) + _trusted_hash = metadata.get("user_api_key_hash") if metadata else None + _key_already_hashed = ( + isinstance(_trusted_hash, str) + and is_valid_sha256_hash(_trusted_hash) + and _trusted_hash == api_key + ) + api_key = _redact_logged_api_key(api_key, already_hashed=_key_already_hashed) or "" if ( standard_logging_payload is not None @@ -299,8 +331,6 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs end_user_id = end_user_id or standard_logging_payload["metadata"].get( "user_api_key_end_user_id" ) - # BUG FIX: Don't overwrite api_key when standard_logging_payload is None - # The api_key was already extracted from metadata (line 243) and hashed (lines 256-259) request_tags = ( safe_dumps(metadata.get("tags", [])) if isinstance(metadata.get("tags", []), list) diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 0c7511589de..67d57d2d6bb 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -7,7 +7,6 @@ from datetime import timezone from typing import Any, cast import pytest -from fastapi.testclient import TestClient sys.path.insert( 0, os.path.abspath("../../../..") @@ -15,7 +14,6 @@ sys.path.insert( from unittest.mock import AsyncMock, MagicMock, patch -import litellm from litellm.constants import ( LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, @@ -30,12 +28,14 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import ( _get_spend_logs_metadata, _get_vector_store_request_for_spend_logs_payload, _is_master_key, + _redact_logged_api_key, _redact_prompt_leaks_in_error_string, _sanitize_error_information_for_spend_logs, _sanitize_request_body_for_spend_logs_payload, _should_store_prompts_and_responses_in_spend_logs, get_logging_payload, ) +from litellm.proxy.utils import hash_token from litellm.types.utils import ( StandardLoggingHiddenParams, StandardLoggingMetadata, @@ -661,8 +661,6 @@ def test_get_logging_payload_api_key_preserved_when_standard_logging_payload_is_ assert payload["model"] == "openai/gpt-4.1" assert payload["user"] == "test_user" - print(f"✅ Test passed! api_key preserved: {payload['api_key']}") - @pytest.mark.asyncio @patch("litellm.proxy.proxy_server.master_key", "sk-master-key") @@ -810,18 +808,6 @@ async def test_api_key_preserved_through_failure_hook_to_database(): assert payload.get("model") == "gpt-3.5-turbo" assert payload.get("user") == "test_user" - print("\n" + "=" * 80) - print("✅ CRITICAL E2E TEST PASSED") - print("=" * 80) - print(f"Token: {data['token']}") - print(f"Payload api_key: {payload_api_key}") - print(f"Match: {data['token'] == payload_api_key}") - print("=" * 80) - print("Production incident bug is FIXED and protected:") - print("- Failed requests preserve api_key through entire flow") - print("- Both SpendLogs AND DailyUserSpend will have correct api_key") - print("=" * 80 + "\n") - @patch("litellm.proxy.proxy_server.master_key", None) @patch("litellm.proxy.proxy_server.general_settings", {}) @@ -2073,3 +2059,255 @@ def test_sanitize_error_information_redacts_pydantic_assignment_form( assert sanitized is not None assert "leaked-via-pydantic-msg" not in sanitized["error_message"] assert REDACTED_BY_LITELM_STRING in sanitized["error_message"] + + +# ── _redact_logged_api_key unit tests ────────────────────────────────────── + + +def test_redact_logged_api_key_none_returns_none(): + assert _redact_logged_api_key(None) is None + + +def test_redact_logged_api_key_empty_string_returns_none(): + assert _redact_logged_api_key("") is None + + +def test_redact_logged_api_key_sk_key_is_hashed(): + raw = "sk-1234secret" + result = _redact_logged_api_key(raw) + assert result == hash_token(raw) + assert result is not None + assert not result.startswith("sk-") + assert len(result) == 64 + + +def test_redact_logged_api_key_bearer_sk_equals_sk_hash(): + raw = "sk-1234secret" + result_plain = _redact_logged_api_key(raw) + result_bearer = _redact_logged_api_key(f"Bearer {raw}") + assert result_bearer == result_plain + + +def test_redact_logged_api_key_bearer_case_insensitive(): + raw = "sk-1234secret" + result_lower = _redact_logged_api_key(f"bearer {raw}") + result_upper = _redact_logged_api_key(f"BEARER {raw}") + expected = hash_token(raw) + assert result_lower == expected + assert result_upper == expected + + +def test_redact_logged_api_key_non_sk_raw_key_is_hashed(): + raw = "anthropic-raw-key-xyz" + result = _redact_logged_api_key(raw) + assert result is not None + assert result != raw + assert len(result) == 64 + assert result == hash_token(raw) + + +def test_redact_logged_api_key_already_valid_sha256_passes_through_with_flag(): + already_hashed = hash_token("sk-some-key") + assert len(already_hashed) == 64 + result = _redact_logged_api_key(already_hashed, already_hashed=True) + assert result == already_hashed + assert hash_token(already_hashed) != result # no double-hash + + +def test_redact_logged_api_key_sha256_without_flag_is_hashed(): + already_hashed = hash_token("sk-some-key") + assert len(already_hashed) == 64 + result = _redact_logged_api_key(already_hashed) + assert result is not None + assert result != already_hashed + assert len(result) == 64 + assert result == hash_token(already_hashed) + + +def test_redact_logged_api_key_hashed_jwt_passes_through(): + jwt_hash = "hashed-jwt-" + "a" * 64 + result = _redact_logged_api_key(jwt_hash) + assert result == jwt_hash + + +def test_redact_logged_api_key_hashed_jwt_short_suffix_is_hashed(): + short_jwt = "hashed-jwt-tooshort" + result = _redact_logged_api_key(short_jwt) + assert result is not None + assert result != short_jwt + assert len(result) == 64 + assert result == hash_token(short_jwt) + + +def test_redact_logged_api_key_bearer_only_returns_none(): + # "bearer " with nothing after stripping is equivalent to no key + assert _redact_logged_api_key("bearer ") is None + assert _redact_logged_api_key("Bearer ") is None + assert _redact_logged_api_key("BEARER ") is None + + +# ── _get_spend_logs_metadata key-hash invariant tests ───────────────────── + + +def test_get_spend_logs_metadata_sk_key_hashed(): + raw = "sk-1234secret" + meta = _get_spend_logs_metadata({"user_api_key": raw}) + assert meta["user_api_key"] == hash_token(raw) + assert meta["user_api_key"] is not None + result = meta["user_api_key"] + assert result is not None + assert not result.startswith("sk-") + assert len(result) == 64 + + +def test_get_spend_logs_metadata_bearer_sk_key_hashed_same_as_plain(): + raw = "sk-1234secret" + meta_plain = _get_spend_logs_metadata({"user_api_key": raw}) + meta_bearer = _get_spend_logs_metadata({"user_api_key": f"Bearer {raw}"}) + assert meta_bearer["user_api_key"] == meta_plain["user_api_key"] + + +def test_get_spend_logs_metadata_non_sk_raw_key_hashed(): + raw = "anthropic-raw-key-xyz" + meta = _get_spend_logs_metadata({"user_api_key": raw}) + result = meta["user_api_key"] + assert result is not None + assert result != raw + assert len(result) == 64 + + +def test_get_spend_logs_metadata_already_hashed_unchanged_with_provenance(): + already_hashed = hash_token("sk-some-key") + meta = _get_spend_logs_metadata( + {"user_api_key": already_hashed, "user_api_key_hash": already_hashed} + ) + assert meta["user_api_key"] == already_hashed + assert hash_token(already_hashed) != meta["user_api_key"] # no double-hash + + +def test_get_spend_logs_metadata_already_hashed_no_provenance_is_rehashed(): + already_hashed = hash_token("sk-some-key") + meta = _get_spend_logs_metadata({"user_api_key": already_hashed}) + assert meta["user_api_key"] != already_hashed + assert meta["user_api_key"] == hash_token(already_hashed) + + +def test_get_spend_logs_metadata_provenance_bypass_requires_hash_match(): + already_hashed = hash_token("sk-some-key") + different_hash = hash_token("sk-other-key") + meta = _get_spend_logs_metadata( + {"user_api_key": already_hashed, "user_api_key_hash": different_hash} + ) + assert meta["user_api_key"] == hash_token(already_hashed) + + +def test_get_spend_logs_metadata_hashed_jwt_unchanged(): + jwt_hash = "hashed-jwt-" + "b" * 64 + meta = _get_spend_logs_metadata({"user_api_key": jwt_hash}) + assert meta["user_api_key"] == jwt_hash + + +def test_get_spend_logs_metadata_none_key_is_none(): + meta = _get_spend_logs_metadata({"user_api_key": None}) + assert meta["user_api_key"] is None + + +# ── get_logging_payload key-hash invariant tests ─────────────────────────── + + +@patch("litellm.proxy.proxy_server.master_key", None) +@patch("litellm.proxy.proxy_server.general_settings", {}) +def test_get_logging_payload_non_sk_raw_key_both_fields_hashed(): + raw = "anthropic-raw-key-xyz" + kwargs = { + "model": "openai/gpt-4.1", + "messages": [{"role": "user", "content": "Hello"}], + "call_type": "acompletion", + "litellm_params": { + "metadata": { + "user_api_key": raw, + "user_api_key_user_id": "test_user", + "user_api_key_team_id": "test_team", + } + }, + } + payload = get_logging_payload( + kwargs=kwargs, + response_obj=Exception("error"), + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + + assert payload["api_key"] != raw + assert len(payload["api_key"]) == 64 + + parsed_meta = json.loads(payload["metadata"]) + assert parsed_meta["user_api_key"] != raw + assert parsed_meta["user_api_key"] is not None + assert len(parsed_meta["user_api_key"]) == 64 + + +@patch("litellm.proxy.proxy_server.master_key", None) +@patch("litellm.proxy.proxy_server.general_settings", {}) +def test_get_logging_payload_empty_key_slp_none_is_empty_string_not_none_literal(): + kwargs = { + "model": "openai/gpt-4.1", + "messages": [{"role": "user", "content": "Hello"}], + "call_type": "acompletion", + "litellm_params": { + "metadata": { + "user_api_key_user_id": "test_user", + } + }, + } + payload = get_logging_payload( + kwargs=kwargs, + response_obj=Exception("error"), + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + + assert payload["api_key"] == "", ( + f"Expected empty string but got {payload['api_key']!r}; " + "dropping _redact_logged_api_key's 'or \"\"' guard would yield 'None' here" + ) + + +def test_get_spend_logs_metadata_sibling_fields_preserved(): + raw = "anthropic-raw-key-xyz" + meta = _get_spend_logs_metadata( + { + "user_api_key": raw, + "user_api_key_alias": "my-alias", + "user_api_key_team_id": "team-123", + } + ) + assert meta["user_api_key"] == hash_token(raw) + assert meta["user_api_key_alias"] == "my-alias" + assert meta["user_api_key_team_id"] == "team-123" + + +def test_redact_logged_api_key_partial_sha256_is_hashed(): + partial_hex = "a" * 63 + result = _redact_logged_api_key(partial_hex) + assert result is not None + assert result != partial_hex + assert len(result) == 64 + assert result == hash_token(partial_hex) + + +def test_redact_logged_api_key_bearer_already_hashed_passes_through_with_flag(): + already_hashed = hash_token("sk-some-key") + assert len(already_hashed) == 64 + result = _redact_logged_api_key(f"Bearer {already_hashed}", already_hashed=True) + assert result == already_hashed + assert hash_token(already_hashed) != result + + +def test_redact_logged_api_key_bearer_sha256_without_flag_is_hashed(): + already_hashed = hash_token("sk-some-key") + assert len(already_hashed) == 64 + result = _redact_logged_api_key(f"Bearer {already_hashed}") + assert result is not None + assert result != already_hashed + assert result == hash_token(already_hashed) From 7e1f44f0cc15e639d8fe95fc8831c5a0b25cc76b Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 30 Jul 2026 02:38:28 +0000 Subject: [PATCH 02/70] feat(proxy): add admin toggle to block requests for models without pricing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/__init__.py | 1 + litellm/proxy/_types.py | 2 + litellm/proxy/auth/auth_checks.py | 33 +++++ .../cost_tracking_settings.py | 65 ++++++++ .../proxy/auth/test_auth_checks.py | 139 +++++++++++++++++- .../test_cost_tracking_settings.py | 56 +++++++ .../_components/cost_tracking_settings.tsx | 47 +++++- .../_components/use_block_unpriced_config.ts | 63 ++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 81 ++++++++++ 9 files changed, 482 insertions(+), 5 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts diff --git a/litellm/__init__.py b/litellm/__init__.py index 3f8c742c5a2..d14f41ad49c 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -443,6 +443,7 @@ max_end_user_budget_id: Optional[str] = None # backwards compatibility — arbitrary client-supplied identifiers still # pass through unchanged. validate_end_user_id_in_db: bool = False +block_requests_for_models_without_pricing: bool = False disable_end_user_cost_tracking: Optional[bool] = None disable_end_user_cost_tracking_prometheus_only: Optional[bool] = None enable_end_user_cost_tracking_prometheus_only: Optional[bool] = None diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c6d4ee1120a..450868edf06 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3540,6 +3540,8 @@ class ProxyErrorTypes(str, enum.Enum): Project does not have access to the model """ + model_cost_map_missing = "model_cost_map_missing" + expired_key = "expired_key" """ Key has expired diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index c46bc110ca8..11a45e78410 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -274,6 +274,22 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: return False +def model_has_no_cost_mapping(model: Optional[str], llm_router: Optional[Router]) -> bool: + if not model or llm_router is None: + return False + + model_group_info = llm_router.get_model_group_info(model_group=model) + if model_group_info is None: + return False + + input_cost = model_group_info.input_cost_per_token or 0 + output_cost = model_group_info.output_cost_per_token or 0 + if input_cost > 0 or output_cost > 0: + return False + + return not _is_cost_explicitly_configured(model, llm_router) + + async def _run_project_checks( project_object: Optional[LiteLLM_ProjectTableCachedObj], _model: Optional[Union[str, List[str]]], @@ -534,6 +550,23 @@ async def common_checks( if route in MODEL_DISCOVERY_ROUTES: skip_budget_checks = True + if ( + litellm.block_requests_for_models_without_pricing + and isinstance(_model, str) + and RouteChecks.is_llm_api_route(route=route) + and model_has_no_cost_mapping(model=_model, llm_router=llm_router) + ): + raise ProxyException( + message=( + f"Model '{_model}' has no pricing in the cost map, so its spend would be tracked as $0. " + "Requests for unpriced models are blocked because 'block_requests_for_models_without_pricing' " + "is enabled. Add pricing for this model (input_cost_per_token/output_cost_per_token) to allow it." + ), + type=ProxyErrorTypes.model_cost_map_missing, + param="model", + code=status.HTTP_403_FORBIDDEN, + ) + # 1. If team is blocked if team_object is not None and team_object.blocked is True: raise Exception(f"Team={team_object.team_id} is blocked. Update via `/team/unblock` if you're an admin.") diff --git a/litellm/proxy/management_endpoints/cost_tracking_settings.py b/litellm/proxy/management_endpoints/cost_tracking_settings.py index cd2c5704778..2c18de6b903 100644 --- a/litellm/proxy/management_endpoints/cost_tracking_settings.py +++ b/litellm/proxy/management_endpoints/cost_tracking_settings.py @@ -13,6 +13,7 @@ POST /cost/estimate - Estimate cost for a given model and token counts from typing import Dict, Optional, Tuple, Union from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel import litellm from litellm._logging import verbose_proxy_logger @@ -407,6 +408,70 @@ async def update_cost_margin_config( ) +class BlockUnpricedModelsRequest(BaseModel): + enabled: bool + + +class BlockUnpricedModelsResponse(BaseModel): + enabled: bool + + +@router.get( + "/config/block_requests_for_models_without_pricing", + tags=["Cost Tracking"], + dependencies=[Depends(user_api_key_auth)], + response_model=BlockUnpricedModelsResponse, +) +async def get_block_requests_for_models_without_pricing() -> BlockUnpricedModelsResponse: + return BlockUnpricedModelsResponse(enabled=bool(litellm.block_requests_for_models_without_pricing)) + + +@router.patch( + "/config/block_requests_for_models_without_pricing", + tags=["Cost Tracking"], + dependencies=[Depends(user_api_key_auth)], + response_model=BlockUnpricedModelsResponse, +) +async def update_block_requests_for_models_without_pricing( + request: BlockUnpricedModelsRequest, +) -> BlockUnpricedModelsResponse: + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_config, + store_model_in_db, + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + if store_model_in_db is not True: + raise HTTPException( + status_code=500, + detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."}, + ) + + try: + config = await proxy_config.get_config() + if "litellm_settings" not in config: + config["litellm_settings"] = {} + config["litellm_settings"]["block_requests_for_models_without_pricing"] = request.enabled + await proxy_config.save_config(new_config=config) + + litellm.block_requests_for_models_without_pricing = request.enabled + verbose_proxy_logger.info(f"Updated block_requests_for_models_without_pricing: {request.enabled}") + + return BlockUnpricedModelsResponse(enabled=request.enabled) + except Exception as e: + verbose_proxy_logger.error(f"Error updating block_requests_for_models_without_pricing: {str(e)}") + raise HTTPException( + status_code=500, + detail={"error": f"Failed to update setting: {str(e)}"}, + ) + + @router.post( "/cost/estimate", tags=["Cost Tracking"], diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 34a353966bf..aec0ddc55f8 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -2,6 +2,7 @@ import asyncio import json import os import sys +from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch sys.path.insert( @@ -5164,4 +5165,140 @@ async def test_get_project_object_db_fetch_returns_cached_obj(): assert isinstance(result, LiteLLM_ProjectTableCachedObj) assert result.project_id == "p-1" - assert result.project_alias == "proj" + + +UNPRICED_UNDERLYING_MODEL = "openai/unpriced-model-lit4984-xyz" + + +def _router_with_priced_and_unpriced_models() -> "Router": + from litellm.router import Router + + return Router( + model_list=[ + { + "model_name": "priced-group", + "litellm_params": {"model": "gpt-3.5-turbo", "api_key": "sk-test"}, + }, + { + "model_name": "unpriced-group", + "litellm_params": {"model": UNPRICED_UNDERLYING_MODEL, "api_key": "sk-test"}, + }, + ] + ) + + +def test_model_has_no_cost_mapping_priced_model_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + + router = _router_with_priced_and_unpriced_models() + + assert model_has_no_cost_mapping(model="priced-group", llm_router=router) is False + + +def test_model_has_no_cost_mapping_unpriced_model_is_true(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + + router = _router_with_priced_and_unpriced_models() + + assert model_has_no_cost_mapping(model="unpriced-group", llm_router=router) is True + + +def test_model_has_no_cost_mapping_no_model_or_router_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + + router = _router_with_priced_and_unpriced_models() + + assert model_has_no_cost_mapping(model=None, llm_router=router) is False + assert model_has_no_cost_mapping(model="unpriced-group", llm_router=None) is False + + +async def _run_common_checks( + model: Optional[str], llm_router: Optional["Router"], route: str = "/chat/completions" +) -> bool: + from fastapi import Request + + from litellm.proxy.auth.auth_checks import common_checks + + return await common_checks( + request_body={"model": model, "messages": [{"role": "user", "content": "hi"}]}, + team_object=None, + user_object=None, + end_user_object=None, + global_proxy_spend=None, + general_settings={}, + route=route, + llm_router=llm_router, + proxy_logging_obj=MagicMock(), + valid_token=UserAPIKeyAuth(token="test-token"), + request=MagicMock(spec=Request), + ) + + +@pytest.mark.asyncio +async def test_common_checks_blocks_unpriced_model_when_enabled(monkeypatch): + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) + router = _router_with_priced_and_unpriced_models() + + with pytest.raises(ProxyException) as exc_info: + await _run_common_checks(model="unpriced-group", llm_router=router) + + assert exc_info.value.code == "403" + assert exc_info.value.type == ProxyErrorTypes.model_cost_map_missing + assert exc_info.value.param == "model" + assert "unpriced-group" in exc_info.value.message + assert "pricing" in exc_info.value.message.lower() + + +@pytest.mark.asyncio +async def test_common_checks_allows_unpriced_model_when_disabled(monkeypatch): + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", False) + router = _router_with_priced_and_unpriced_models() + + result = await _run_common_checks(model="unpriced-group", llm_router=router) + + assert result is True + + +@pytest.mark.asyncio +async def test_common_checks_allows_priced_model_when_enabled(monkeypatch): + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) + router = _router_with_priced_and_unpriced_models() + + result = await _run_common_checks(model="priced-group", llm_router=router) + + assert result is True + + +@pytest.mark.asyncio +async def test_common_checks_ignores_non_llm_route_when_enabled(monkeypatch): + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) + router = _router_with_priced_and_unpriced_models() + + result = await _run_common_checks( + model="unpriced-group", llm_router=router, route="/model/new" + ) + + assert result is True + + +@pytest.mark.asyncio +async def test_common_checks_blocks_alias_resolving_to_unpriced_model(monkeypatch): + from litellm.router import Router + + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) + router = Router( + model_list=[ + { + "model_name": "billed-underlying-group", + "litellm_params": {"model": UNPRICED_UNDERLYING_MODEL, "api_key": "sk-test"}, + } + ], + model_group_alias={"public-alias": "billed-underlying-group"}, + ) + + with pytest.raises(ProxyException) as exc_info: + await _run_common_checks(model="public-alias", llm_router=router) + + assert exc_info.value.code == "403" + assert exc_info.value.type == ProxyErrorTypes.model_cost_map_missing + assert "public-alias" in exc_info.value.message diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index bc463d5e75d..4fb90e9fb2d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -500,3 +500,59 @@ class TestResolveModelForCostLookup: assert resolved_model == "openai/gpt-4" assert provider is None + + +class TestBlockRequestsForModelsWithoutPricing: + """Test suite for the block_requests_for_models_without_pricing toggle endpoints""" + + @pytest.mark.asyncio + async def test_get_reflects_in_memory_flag(self): + with patch.object(litellm, "block_requests_for_models_without_pricing", True): + response = client.get( + "/config/block_requests_for_models_without_pricing", + headers={"Authorization": "Bearer sk-1234"}, + ) + + assert response.status_code == 200 + assert response.json() == {"enabled": True} + + @pytest.mark.asyncio + async def test_patch_persists_and_updates_flag(self): + mock_proxy_config = AsyncMock() + mock_proxy_config.get_config = AsyncMock(return_value={"litellm_settings": {}}) + mock_proxy_config.save_config = AsyncMock() + + with ( + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config), + patch("litellm.proxy.proxy_server.store_model_in_db", True), + patch.object(litellm, "block_requests_for_models_without_pricing", False), + ): + response = client.patch( + "/config/block_requests_for_models_without_pricing", + headers={"Authorization": "Bearer sk-1234"}, + json={"enabled": True}, + ) + + assert response.status_code == 200 + assert response.json() == {"enabled": True} + assert litellm.block_requests_for_models_without_pricing is True + + saved_config = mock_proxy_config.save_config.call_args.kwargs["new_config"] + assert saved_config["litellm_settings"]["block_requests_for_models_without_pricing"] is True + + @pytest.mark.asyncio + async def test_patch_requires_store_model_in_db(self): + with ( + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.proxy_config", AsyncMock()), + patch("litellm.proxy.proxy_server.store_model_in_db", False), + ): + response = client.patch( + "/config/block_requests_for_models_without_pricing", + headers={"Authorization": "Bearer sk-1234"}, + json={"enabled": True}, + ) + + assert response.status_code == 500 + assert "error" in response.json()["detail"] diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx index b32e7afd756..f6a3d487ade 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/cost_tracking_settings.tsx @@ -12,7 +12,7 @@ import { TabPanels, TabPanel, } from "@tremor/react"; -import { Modal, Form } from "antd"; +import { Modal, Form, Switch } from "antd"; import { CostTrackingSettingsProps } from "./types"; import ProviderDiscountTable from "./provider_discount_table"; import AddProviderForm from "./add_provider_form"; @@ -24,6 +24,7 @@ import { DocsMenu } from "@/components/HelpLink"; import HowItWorks from "./how_it_works"; import { useDiscountConfig } from "./use_discount_config"; import { useMarginConfig } from "./use_margin_config"; +import { useBlockUnpricedConfig } from "./use_block_unpriced_config"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; const DOCS_LINKS = [ @@ -65,9 +66,16 @@ const CostTrackingSettings: React.FC = ({ userID, use handleMarginChange, } = useMarginConfig({ accessToken }); + const { + blockUnpriced, + isUpdating: isUpdatingBlockUnpriced, + fetchBlockUnpriced, + setBlockUnpriced, + } = useBlockUnpricedConfig({ accessToken }); + useEffect(() => { if (accessToken) { - Promise.all([fetchDiscountConfig(), fetchMarginConfig()]).finally(() => { + Promise.all([fetchDiscountConfig(), fetchMarginConfig(), fetchBlockUnpriced()]).finally(() => { setIsFetching(false); }); @@ -82,7 +90,7 @@ const CostTrackingSettings: React.FC = ({ userID, use }; loadModels(); } - }, [accessToken, fetchDiscountConfig, fetchMarginConfig]); + }, [accessToken, fetchDiscountConfig, fetchMarginConfig, fetchBlockUnpriced]); const handleAddProvider = async () => { const success = await addProvider(selectedProvider, newDiscount); @@ -293,7 +301,38 @@ const CostTrackingSettings: React.FC = ({ userID, use )} - {/* Accordion 3: Pricing Calculator - Available to all roles */} + {isProxyAdmin && ( + + +
+ Block Unpriced Models + + Reject requests for models that have no pricing in the cost map instead of logging them as $0 spend + +
+
+ +
+
+
+ Block requests for models without pricing + + When enabled, a request whose resolved model has no cost mapping is rejected with a 403 so an + admin can add pricing for it. Off by default + +
+ setBlockUnpriced(checked)} + /> +
+
+
+
+ )} + + {/* Accordion 4: Pricing Calculator - Available to all roles */}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts new file mode 100644 index 00000000000..f4a110c7878 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts @@ -0,0 +1,63 @@ +import { useState, useCallback } from "react"; +import { apiClient } from "@/components/networking"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +export interface UseBlockUnpricedConfigProps { + accessToken: string | null; +} + +export interface UseBlockUnpricedConfigReturn { + blockUnpriced: boolean; + isUpdating: boolean; + fetchBlockUnpriced: () => Promise; + setBlockUnpriced: (enabled: boolean) => Promise; +} + +interface BlockUnpricedResponse { + enabled: boolean; +} + +const ENDPOINT = "/config/block_requests_for_models_without_pricing"; + +export function useBlockUnpricedConfig({ accessToken }: UseBlockUnpricedConfigProps): UseBlockUnpricedConfigReturn { + const [blockUnpriced, setBlockUnpricedState] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + + const fetchBlockUnpriced = useCallback(async () => { + if (!accessToken) return; + try { + const data = await apiClient.get(ENDPOINT, { accessToken }); + setBlockUnpricedState(Boolean(data?.enabled)); + } catch (error) { + console.error("Error fetching block-unpriced-models setting:", error); + } + }, [accessToken]); + + const setBlockUnpriced = useCallback( + async (enabled: boolean) => { + if (!accessToken) return; + setIsUpdating(true); + try { + const data = await apiClient.patch(ENDPOINT, { accessToken, body: { enabled } }); + setBlockUnpricedState(Boolean(data?.enabled)); + NotificationsManager.success( + enabled + ? "Requests for models without pricing will now be blocked" + : "Requests for models without pricing are now allowed", + ); + } catch (error) { + console.error("Error updating block-unpriced-models setting:", error); + } finally { + setIsUpdating(false); + } + }, + [accessToken], + ); + + return { + blockUnpriced, + isUpdating, + fetchBlockUnpriced, + setBlockUnpriced, + }; +} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ed975c6be0a..5abe3b4d587 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -1844,6 +1844,24 @@ export interface paths { patch?: never; trace?: never; }; + "/config/block_requests_for_models_without_pricing": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Block Requests For Models Without Pricing */ + get: operations["get_block_requests_for_models_without_pricing_config_block_requests_for_models_without_pricing_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update Block Requests For Models Without Pricing */ + patch: operations["update_block_requests_for_models_without_pricing_config_block_requests_for_models_without_pricing_patch"]; + trace?: never; + }; "/config/callback/delete": { parameters: { query?: never; @@ -21247,6 +21265,16 @@ export interface components { /** Team Id */ team_id: string; }; + /** BlockUnpricedModelsRequest */ + BlockUnpricedModelsRequest: { + /** Enabled */ + enabled: boolean; + }; + /** BlockUnpricedModelsResponse */ + BlockUnpricedModelsResponse: { + /** Enabled */ + enabled: boolean; + }; /** BlockUsers */ BlockUsers: { /** User Ids */ @@ -37097,6 +37125,59 @@ export interface operations { }; }; }; + get_block_requests_for_models_without_pricing_config_block_requests_for_models_without_pricing_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BlockUnpricedModelsResponse"]; + }; + }; + }; + }; + update_block_requests_for_models_without_pricing_config_block_requests_for_models_without_pricing_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BlockUnpricedModelsRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BlockUnpricedModelsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_callback_config_callback_delete_post: { parameters: { query?: never; From 84c41dcdc3e55c180255433538f6df8969a56297 Mon Sep 17 00:00:00 2001 From: mateo Date: Fri, 31 Jul 2026 03:02:10 +0000 Subject: [PATCH 03/70] fix(proxy): treat non-token pricing as priced and propagate the unpriced-model toggle across workers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 1 + litellm/proxy/auth/auth_checks.py | 58 +++++++++++++++++-- .../proxy/auth/test_auth_checks.py | 45 ++++++++++++++ .../test_cost_tracking_settings.py | 14 +++++ 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 1014b472c61..b0ff992931f 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1525,6 +1525,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [ "public_model_groups_links", "cost_discount_config", "cost_margin_config", + "block_requests_for_models_without_pricing", "budget_exceeded_throttle_percentage", # Every field editable from the Admin UI (proxy_server._GENERAL_SETTINGS_UI_LITELLM_FIELDS) # must be listed here so a DB write from one worker overrides the live litellm attribute on diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 11a45e78410..136b8d19c04 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -13,7 +13,18 @@ import asyncio import math import re import time -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Type, Union, cast +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Mapping, + Optional, + Type, + Union, + cast, +) from fastapi import HTTPException, Request, status from pydantic import BaseModel @@ -274,17 +285,52 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: return False +def _has_positive_cost(value: object) -> bool: + if isinstance(value, bool): + return False + if isinstance(value, (int, float)): + return value > 0 + if isinstance(value, dict): + return any(_has_positive_cost(nested) for nested in value.values()) + return False + + +def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: + return any("cost_per" in key and _has_positive_cost(value) for key, value in entry.items()) + + +def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: + """ + Check every deployment behind a model group for a positive price on any billed + metric (tokens, characters, seconds, pages, images, queries, ...), so models that + are billed by a non-token metric are not treated as unpriced. + """ + for deployment in llm_router.get_model_list(model_name=model) or []: + litellm_params = deployment.get("litellm_params") or {} + if _entry_has_priced_metric(litellm_params): + return True + + model_id = (deployment.get("model_info") or {}).get("id") + if model_id is None: + continue + + model_info = llm_router.get_deployment_model_info( + model_id=model_id, model_name=litellm_params.get("model") or "" + ) + if model_info is not None and _entry_has_priced_metric(model_info): + return True + + return False + + def model_has_no_cost_mapping(model: Optional[str], llm_router: Optional[Router]) -> bool: if not model or llm_router is None: return False - model_group_info = llm_router.get_model_group_info(model_group=model) - if model_group_info is None: + if llm_router.get_model_group_info(model_group=model) is None: return False - input_cost = model_group_info.input_cost_per_token or 0 - output_cost = model_group_info.output_cost_per_token or 0 - if input_cost > 0 or output_cost > 0: + if _model_group_has_pricing(model=model, llm_router=llm_router): return False return not _is_cost_explicitly_configured(model, llm_router) diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index aec0ddc55f8..a078e041aa7 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -5165,6 +5165,7 @@ async def test_get_project_object_db_fetch_returns_cached_obj(): assert isinstance(result, LiteLLM_ProjectTableCachedObj) assert result.project_id == "p-1" + assert result.project_alias == "proj" UNPRICED_UNDERLYING_MODEL = "openai/unpriced-model-lit4984-xyz" @@ -5212,6 +5213,50 @@ def test_model_has_no_cost_mapping_no_model_or_router_is_false(): assert model_has_no_cost_mapping(model="unpriced-group", llm_router=None) is False +@pytest.mark.parametrize( + "underlying_model", + [ + "azure/speech/azure-tts", + "mistral/mistral-ocr-latest", + "vertex_ai/imagen-3.0-generate-001", + ], +) +def test_model_has_no_cost_mapping_non_token_priced_model_is_false(underlying_model): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "non-token-priced-group", + "litellm_params": {"model": underlying_model, "api_key": "sk-test"}, + } + ] + ) + + assert model_has_no_cost_mapping(model="non-token-priced-group", llm_router=router) is False + + +def test_model_has_no_cost_mapping_non_token_price_from_litellm_params_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "custom-tts", + "litellm_params": { + "model": UNPRICED_UNDERLYING_MODEL, + "api_key": "sk-test", + "input_cost_per_second": 0.0001, + }, + } + ] + ) + + assert model_has_no_cost_mapping(model="custom-tts", llm_router=router) is False + + async def _run_common_checks( model: Optional[str], llm_router: Optional["Router"], route: str = "/chat/completions" ) -> bool: diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index 4fb90e9fb2d..1124a4a31d4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -541,6 +541,20 @@ class TestBlockRequestsForModelsWithoutPricing: saved_config = mock_proxy_config.save_config.call_args.kwargs["new_config"] assert saved_config["litellm_settings"]["block_requests_for_models_without_pricing"] is True + def test_peer_workers_pick_up_persisted_flag_on_config_reload(self): + """A PATCH only mutates the flag on the worker that served it; peer workers must pick the + persisted value up when they reload litellm_settings from the DB.""" + from litellm.proxy.proxy_server import ProxyConfig + + with patch.object(litellm, "block_requests_for_models_without_pricing", False): + ProxyConfig()._update_config_fields( + current_config={}, + param_name="litellm_settings", + db_param_value={"block_requests_for_models_without_pricing": True}, + ) + + assert litellm.block_requests_for_models_without_pricing is True + @pytest.mark.asyncio async def test_patch_requires_store_model_in_db(self): with ( From 074b37b4f987f239725a165565be16ff5e1f9686 Mon Sep 17 00:00:00 2001 From: mateo Date: Fri, 31 Jul 2026 03:08:29 +0000 Subject: [PATCH 04/70] refactor(proxy): flatten the pricing-metric check to avoid recursion Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_checks.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 136b8d19c04..3639ef245cf 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -285,18 +285,19 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: return False -def _has_positive_cost(value: object) -> bool: - if isinstance(value, bool): - return False - if isinstance(value, (int, float)): - return value > 0 - if isinstance(value, dict): - return any(_has_positive_cost(nested) for nested in value.values()) - return False +def _is_positive_cost(value: object) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: - return any("cost_per" in key and _has_positive_cost(value) for key, value in entry.items()) + for key, value in entry.items(): + if "cost_per" not in key: + continue + if _is_positive_cost(value): + return True + if isinstance(value, dict) and any(_is_positive_cost(nested) for nested in value.values()): + return True + return False def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: From 9c922f4aa4f6ab3a261326332d01b74e3717e63c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:49:10 +0000 Subject: [PATCH 05/70] fix(bedrock): forward provider response headers on chat completions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../litellm_core_utils/streaming_handler.py | 2 +- litellm/llms/bedrock/chat/converse_handler.py | 18 ++++-- litellm/llms/bedrock/chat/invoke_handler.py | 8 +-- .../base_invoke_transformation.py | 64 +++++++++---------- litellm/llms/custom_httpx/llm_http_handler.py | 2 + litellm/types/utils.py | 7 +- .../llm_translation/test_bedrock_moonshot.py | 19 ++---- .../llms/bedrock/chat/test_invoke_handler.py | 25 ++++++++ .../llms/chat/test_converse_handler.py | 55 ++++++++++++++++ 9 files changed, 143 insertions(+), 57 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 99b1c1a2ab7..fe107d349c6 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -164,7 +164,7 @@ class CustomStreamWrapper: custom_llm_provider: str | None = None, stream_options=None, make_call: Callable | None = None, - _response_headers: dict | None = None, + _response_headers: dict | httpx.Headers | None = None, ): self.model = model self.make_call = make_call diff --git a/litellm/llms/bedrock/chat/converse_handler.py b/litellm/llms/bedrock/chat/converse_handler.py index 25e544f4521..c9ea06d37dc 100644 --- a/litellm/llms/bedrock/chat/converse_handler.py +++ b/litellm/llms/bedrock/chat/converse_handler.py @@ -33,7 +33,7 @@ def make_sync_call( json_mode: bool | None = False, fake_stream: bool = False, stream_chunk_size: int | None = None, -): +) -> tuple[Any, httpx.Headers]: if client is None: client = _get_httpx_client() # Create a new client if none provided @@ -74,7 +74,7 @@ def make_sync_call( additional_args={"complete_input_dict": data}, ) - return completion_stream + return completion_stream, response.headers class BedrockConverseLLM(BaseAWSLLM): @@ -132,7 +132,7 @@ class BedrockConverseLLM(BaseAWSLLM): }, ) - completion_stream: Final = await make_call( + completion_stream, response_headers = await make_call( client=client, api_base=api_base, headers=dict(prepped.headers), @@ -149,6 +149,7 @@ class BedrockConverseLLM(BaseAWSLLM): model=model, custom_llm_provider="bedrock", logging_obj=logging_obj, + _response_headers=response_headers, ) return streaming_response @@ -225,7 +226,7 @@ class BedrockConverseLLM(BaseAWSLLM): except httpx.TimeoutException: raise BedrockError(status_code=408, message="Timeout error occurred.") - return litellm.AmazonConverseConfig()._transform_response( + transformed_response: Final = litellm.AmazonConverseConfig()._transform_response( model=model, response=response, model_response=model_response, @@ -237,6 +238,8 @@ class BedrockConverseLLM(BaseAWSLLM): optional_params=optional_params, encoding=encoding, ) + transformed_response.set_provider_response_headers(response.headers) + return transformed_response def completion( self, @@ -440,7 +443,7 @@ class BedrockConverseLLM(BaseAWSLLM): client = client if stream is not None and stream is True: - completion_stream: Final = make_sync_call( + completion_stream, response_headers = make_sync_call( client=(client if client is not None and isinstance(client, HTTPHandler) else None), api_base=proxy_endpoint_url, headers=prepped.headers, @@ -457,6 +460,7 @@ class BedrockConverseLLM(BaseAWSLLM): model=model, custom_llm_provider="bedrock", logging_obj=logging_obj, + _response_headers=response_headers, ) return streaming_response @@ -477,7 +481,7 @@ class BedrockConverseLLM(BaseAWSLLM): except httpx.TimeoutException: raise BedrockError(status_code=408, message="Timeout error occurred.") - return litellm.AmazonConverseConfig()._transform_response( + sync_transformed_response: Final = litellm.AmazonConverseConfig()._transform_response( model=model, response=response, model_response=model_response, @@ -489,3 +493,5 @@ class BedrockConverseLLM(BaseAWSLLM): optional_params=optional_params, encoding=encoding, ) + sync_transformed_response.set_provider_response_headers(response.headers) + return sync_transformed_response diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py index 8d2b3dae71b..21892af3aae 100644 --- a/litellm/llms/bedrock/chat/invoke_handler.py +++ b/litellm/llms/bedrock/chat/invoke_handler.py @@ -163,7 +163,7 @@ async def make_call( json_mode: bool | None = False, bedrock_invoke_provider: litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL | None = None, stream_chunk_size: int | None = None, -): +) -> tuple[Any, httpx.Headers]: try: if client is None: client = get_async_httpx_client( @@ -225,7 +225,7 @@ async def make_call( additional_args={"complete_input_dict": data}, ) - return completion_stream + return completion_stream, response.headers except httpx.HTTPStatusError as err: error_code: Final = err.response.status_code raise BedrockError(status_code=error_code, message=err.response.text) @@ -248,7 +248,7 @@ def make_sync_call( json_mode: bool | None = False, bedrock_invoke_provider: litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL | None = None, stream_chunk_size: int | None = None, -): +) -> tuple[Any, httpx.Headers]: try: if client is None: client = _get_httpx_client( @@ -309,7 +309,7 @@ def make_sync_call( additional_args={"complete_input_dict": data}, ) - return completion_stream + return completion_stream, response.headers except httpx.HTTPStatusError as err: error_code: Final = err.response.status_code raise BedrockError(status_code=error_code, message=err.response.text) diff --git a/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py index 430d0a92b51..0b5855ccbd4 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/base_invoke_transformation.py @@ -1,7 +1,6 @@ import copy import json import time -from functools import partial from typing import TYPE_CHECKING, Any, Final, cast, get_args import httpx @@ -444,24 +443,24 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM): json_mode: bool | None = None, signed_json_body: bytes | None = None, ) -> CustomStreamWrapper: + completion_stream, response_headers = await make_call( + client=client, + api_base=api_base, + headers=headers, + data=json.dumps(data), + model=model, + messages=messages, + logging_obj=logging_obj, + fake_stream=True if "ai21" in api_base else False, + bedrock_invoke_provider=self.get_bedrock_invoke_provider(model), + json_mode=json_mode, + ) streaming_response: Final = CustomStreamWrapper( - completion_stream=None, - make_call=partial( - make_call, - client=client, - api_base=api_base, - headers=headers, - data=json.dumps(data), - model=model, - messages=messages, - logging_obj=logging_obj, - fake_stream=True if "ai21" in api_base else False, - bedrock_invoke_provider=self.get_bedrock_invoke_provider(model), - json_mode=json_mode, - ), + completion_stream=completion_stream, model=model, custom_llm_provider="bedrock", logging_obj=logging_obj, + _response_headers=response_headers, ) return streaming_response @@ -479,27 +478,28 @@ class AmazonInvokeConfig(BaseConfig, BaseAWSLLM): json_mode: bool | None = None, signed_json_body: bytes | None = None, ) -> CustomStreamWrapper: - if client is None or isinstance(client, AsyncHTTPHandler): - client = _get_httpx_client(params={}) + sync_client: Final = ( + _get_httpx_client(params={}) if client is None or isinstance(client, AsyncHTTPHandler) else client + ) + completion_stream, response_headers = make_sync_call( + client=sync_client, + api_base=api_base, + headers=headers, + data=json.dumps(data), + signed_json_body=signed_json_body, + model=model, + messages=messages, + logging_obj=logging_obj, + fake_stream=True if "ai21" in api_base else False, + bedrock_invoke_provider=self.get_bedrock_invoke_provider(model), + json_mode=json_mode, + ) streaming_response: Final = CustomStreamWrapper( - completion_stream=None, - make_call=partial( - make_sync_call, - client=client, - api_base=api_base, - headers=headers, - data=json.dumps(data), - signed_json_body=signed_json_body, - model=model, - messages=messages, - logging_obj=logging_obj, - fake_stream=True if "ai21" in api_base else False, - bedrock_invoke_provider=self.get_bedrock_invoke_provider(model), - json_mode=json_mode, - ), + completion_stream=completion_stream, model=model, custom_llm_provider="bedrock", logging_obj=logging_obj, + _response_headers=response_headers, ) return streaming_response diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 721b9545ac1..36bcf58a243 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -608,6 +608,7 @@ class BaseLLMHTTPHandler: model=model, custom_llm_provider=custom_llm_provider, logging_obj=logging_obj, + _response_headers=headers, ) if client is None or not isinstance(client, HTTPHandler): @@ -771,6 +772,7 @@ class BaseLLMHTTPHandler: model=model, custom_llm_provider=custom_llm_provider, logging_obj=logging_obj, + _response_headers=_response_headers, ) return streamwrapper diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 220826ccbca..f49b5735f04 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -11,6 +11,7 @@ from typing import ( get_args, ) +import httpx from openai._models import BaseModel as OpenAIObject from openai.types.audio.transcription_create_params import ( FileTypes as FileTypes, @@ -49,7 +50,7 @@ from litellm.types.llms.base import ( ) from litellm.types.mcp import MCPServerCostInfo -from ..litellm_core_utils.core_helpers import map_finish_reason +from ..litellm_core_utils.core_helpers import map_finish_reason, process_response_headers from .agents import LiteLLMSendMessageResponse from .guardrails import GuardrailEventHooks from .llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse @@ -1896,6 +1897,10 @@ class ModelResponseBase(OpenAIObject): _response_headers: dict | None = None + def set_provider_response_headers(self, headers: httpx.Headers) -> None: + """Surface a provider's raw response headers to the caller as `llm_provider-*` headers.""" + self._hidden_params["additional_headers"] = process_response_headers(headers) + def model_dump(self, **kwargs): """Default to exclude_unset to avoid Pydantic serializer warnings for OpenAIObject-derived types.""" if "exclude_unset" not in kwargs and "exclude_none" not in kwargs: diff --git a/tests/llm_translation/test_bedrock_moonshot.py b/tests/llm_translation/test_bedrock_moonshot.py index a9f4a86b3b6..c777305d562 100644 --- a/tests/llm_translation/test_bedrock_moonshot.py +++ b/tests/llm_translation/test_bedrock_moonshot.py @@ -209,14 +209,12 @@ class TestBedrockMoonshotInvoke(BaseLLMChatTest): not exercised here — moonshot streaming delegates to the OpenAI parser and is covered by the OpenAI test suite. - Note: bedrock invoke streaming cannot be intercepted by patching - the caller-supplied client, because ``CustomStreamWrapper.fetch_sync_stream`` - at streaming_handler.py invokes the stored ``make_call`` partial with - ``client=litellm.module_level_client``, which overrides any client the - caller passed. Patch ``make_sync_call`` at its import site in - ``base_invoke_transformation`` so we observe the exact kwargs the - partial was built with at stream-wrapper construction time. + Patch ``make_sync_call`` at its import site in + ``base_invoke_transformation`` so we observe the exact kwargs it is + called with at stream-wrapper construction time. """ + import httpx + from litellm.utils import CustomStreamWrapper captured: dict = {} @@ -225,7 +223,7 @@ class TestBedrockMoonshotInvoke(BaseLLMChatTest): captured.update(kwargs) # Return an empty iterator so the stream wrapper's iteration # doesn't try to parse real bytes. - return iter([]) + return iter([]), httpx.Headers() with patch( "litellm.llms.bedrock.chat.invoke_transformations." @@ -246,11 +244,6 @@ class TestBedrockMoonshotInvoke(BaseLLMChatTest): aws_region_name="us-west-2", ) assert isinstance(response, CustomStreamWrapper) - # Trigger fetch_sync_stream → make_call(...) → fake_make_sync_call. - try: - next(iter(response)) - except StopIteration: - pass assert captured, "make_sync_call was never invoked" assert captured["api_base"].endswith("/invoke-with-response-stream") diff --git a/tests/test_litellm/llms/bedrock/chat/test_invoke_handler.py b/tests/test_litellm/llms/bedrock/chat/test_invoke_handler.py index ee50b9db015..f211cdac475 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_invoke_handler.py +++ b/tests/test_litellm/llms/bedrock/chat/test_invoke_handler.py @@ -2,17 +2,20 @@ import os import sys from unittest.mock import AsyncMock, MagicMock +import httpx import pytest sys.path.insert( 0, os.path.abspath("../../../../..") ) # Adds the parent directory to the system path +import litellm from litellm.llms.bedrock.chat.invoke_handler import ( AWSEventStreamDecoder, make_call, make_sync_call, ) +from litellm.llms.custom_httpx.http_handler import HTTPHandler def test_transform_thinking_blocks_with_redacted_content(): @@ -293,3 +296,25 @@ def test_make_sync_call_honors_explicit_stream_chunk_size(): response.iter_bytes.assert_called_once_with(chunk_size=2048) + +def test_invoke_streaming_forwards_bedrock_response_headers(): + """Streaming callers need `x-amzn-requestid` to correlate a LiteLLM request with AWS support.""" + response = MagicMock() + response.status_code = 200 + response.iter_bytes = MagicMock(return_value=iter([])) + response.headers = httpx.Headers({"x-amzn-requestid": "req-789"}) + client = HTTPHandler() + client.post = MagicMock(return_value=response) + + stream = litellm.completion( + model="bedrock/invoke/anthropic.claude-haiku-4-5-20251001-v1:0", + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=client, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-east-1", + ) + + assert stream._hidden_params["additional_headers"]["llm_provider-x-amzn-requestid"] == "req-789" + diff --git a/tests/test_litellm/llms/chat/test_converse_handler.py b/tests/test_litellm/llms/chat/test_converse_handler.py index 2a3db5982ef..89ab29122d7 100644 --- a/tests/test_litellm/llms/chat/test_converse_handler.py +++ b/tests/test_litellm/llms/chat/test_converse_handler.py @@ -1,7 +1,9 @@ +import json import os import sys from unittest.mock import MagicMock +import httpx import pytest import litellm @@ -202,6 +204,59 @@ def test_make_sync_call_honors_explicit_stream_chunk_size(): response.iter_bytes.assert_called_once_with(chunk_size=2048) +def _converse_response_body() -> dict: + return { + "output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2}, + } + + +def test_converse_completion_forwards_bedrock_response_headers(): + """Bedrock returns x-amzn-requestid on every converse call, which customers need to + correlate proxy requests with AWS support cases, so it must reach the caller as + llm_provider-x-amzn-requestid.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value=_converse_response_body()) + mock_response.text = json.dumps(_converse_response_body()) + mock_response.headers = httpx.Headers({"x-amzn-requestid": "req-123"}) + client = HTTPHandler() + client.post = MagicMock(return_value=mock_response) + + response = litellm.completion( + model="bedrock/converse/anthropic.claude-haiku-4-5-20251001-v1:0", + messages=[{"role": "user", "content": "hi"}], + client=client, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-east-1", + ) + + assert response._hidden_params["additional_headers"]["llm_provider-x-amzn-requestid"] == "req-123" + + +def test_converse_streaming_forwards_bedrock_response_headers(): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.iter_bytes = MagicMock(return_value=iter([])) + mock_response.headers = httpx.Headers({"x-amzn-requestid": "req-456"}) + client = HTTPHandler() + client.post = MagicMock(return_value=mock_response) + + response = litellm.completion( + model="bedrock/converse/anthropic.claude-haiku-4-5-20251001-v1:0", + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=client, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-east-1", + ) + + assert response._hidden_params["additional_headers"]["llm_provider-x-amzn-requestid"] == "req-456" + + def test_completion_plumbs_stream_chunk_size_through_converse(): iter_bytes_spy = _stream_completion_with_spied_iter_bytes( model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0" From 726db1a4c118a2ab92214ba741efacdce7fe25b5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:07:14 +0000 Subject: [PATCH 06/70] test(bedrock): cover async header forwarding for converse and invoke Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llm_translation/test_bedrock_moonshot.py | 3 +- .../llms/bedrock/chat/test_invoke_handler.py | 29 +++++++++- .../llms/chat/test_converse_handler.py | 55 +++++++++++++++++-- 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/tests/llm_translation/test_bedrock_moonshot.py b/tests/llm_translation/test_bedrock_moonshot.py index c777305d562..61364cf2caa 100644 --- a/tests/llm_translation/test_bedrock_moonshot.py +++ b/tests/llm_translation/test_bedrock_moonshot.py @@ -12,6 +12,7 @@ This test suite verifies: """ from base_llm_unit_tests import BaseLLMChatTest +import httpx import pytest import sys import os @@ -213,8 +214,6 @@ class TestBedrockMoonshotInvoke(BaseLLMChatTest): ``base_invoke_transformation`` so we observe the exact kwargs it is called with at stream-wrapper construction time. """ - import httpx - from litellm.utils import CustomStreamWrapper captured: dict = {} diff --git a/tests/test_litellm/llms/bedrock/chat/test_invoke_handler.py b/tests/test_litellm/llms/bedrock/chat/test_invoke_handler.py index f211cdac475..e8964910c69 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_invoke_handler.py +++ b/tests/test_litellm/llms/bedrock/chat/test_invoke_handler.py @@ -15,7 +15,7 @@ from litellm.llms.bedrock.chat.invoke_handler import ( make_call, make_sync_call, ) -from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler def test_transform_thinking_blocks_with_redacted_content(): @@ -298,7 +298,6 @@ def test_make_sync_call_honors_explicit_stream_chunk_size(): def test_invoke_streaming_forwards_bedrock_response_headers(): - """Streaming callers need `x-amzn-requestid` to correlate a LiteLLM request with AWS support.""" response = MagicMock() response.status_code = 200 response.iter_bytes = MagicMock(return_value=iter([])) @@ -318,3 +317,29 @@ def test_invoke_streaming_forwards_bedrock_response_headers(): assert stream._hidden_params["additional_headers"]["llm_provider-x-amzn-requestid"] == "req-789" + +@pytest.mark.asyncio +async def test_async_invoke_streaming_forwards_bedrock_response_headers(): + async def _no_bytes(chunk_size=None): + return + yield b"" + + response = MagicMock() + response.status_code = 200 + response.aiter_bytes = _no_bytes + response.headers = httpx.Headers({"x-amzn-requestid": "req-987"}) + client = AsyncHTTPHandler() + client.post = AsyncMock(return_value=response) + + stream = await litellm.acompletion( + model="bedrock/invoke/anthropic.claude-haiku-4-5-20251001-v1:0", + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=client, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-east-1", + ) + + assert stream._hidden_params["additional_headers"]["llm_provider-x-amzn-requestid"] == "req-987" + diff --git a/tests/test_litellm/llms/chat/test_converse_handler.py b/tests/test_litellm/llms/chat/test_converse_handler.py index 89ab29122d7..6f8a2788c38 100644 --- a/tests/test_litellm/llms/chat/test_converse_handler.py +++ b/tests/test_litellm/llms/chat/test_converse_handler.py @@ -1,7 +1,7 @@ import json import os import sys -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -10,7 +10,7 @@ import litellm from litellm.llms.bedrock.chat import BedrockConverseLLM from litellm.llms.bedrock.chat.converse_handler import make_sync_call from litellm.llms.bedrock.common_utils import _get_all_bedrock_regions -from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler sys.path.insert( 0, os.path.abspath("../../../../..") @@ -213,9 +213,6 @@ def _converse_response_body() -> dict: def test_converse_completion_forwards_bedrock_response_headers(): - """Bedrock returns x-amzn-requestid on every converse call, which customers need to - correlate proxy requests with AWS support cases, so it must reach the caller as - llm_provider-x-amzn-requestid.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json = MagicMock(return_value=_converse_response_body()) @@ -257,6 +254,54 @@ def test_converse_streaming_forwards_bedrock_response_headers(): assert response._hidden_params["additional_headers"]["llm_provider-x-amzn-requestid"] == "req-456" +@pytest.mark.asyncio +async def test_async_converse_completion_forwards_bedrock_response_headers(): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json = MagicMock(return_value=_converse_response_body()) + mock_response.text = json.dumps(_converse_response_body()) + mock_response.headers = httpx.Headers({"x-amzn-requestid": "req-abc"}) + client = AsyncHTTPHandler() + client.post = AsyncMock(return_value=mock_response) + + response = await litellm.acompletion( + model="bedrock/converse/anthropic.claude-haiku-4-5-20251001-v1:0", + messages=[{"role": "user", "content": "hi"}], + client=client, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-east-1", + ) + + assert response._hidden_params["additional_headers"]["llm_provider-x-amzn-requestid"] == "req-abc" + + +@pytest.mark.asyncio +async def test_async_converse_streaming_forwards_bedrock_response_headers(): + async def _no_bytes(chunk_size=None): + return + yield b"" + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.aiter_bytes = _no_bytes + mock_response.headers = httpx.Headers({"x-amzn-requestid": "req-def"}) + client = AsyncHTTPHandler() + client.post = AsyncMock(return_value=mock_response) + + response = await litellm.acompletion( + model="bedrock/converse/anthropic.claude-haiku-4-5-20251001-v1:0", + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=client, + aws_access_key_id="fake", + aws_secret_access_key="fake", + aws_region_name="us-east-1", + ) + + assert response._hidden_params["additional_headers"]["llm_provider-x-amzn-requestid"] == "req-def" + + def test_completion_plumbs_stream_chunk_size_through_converse(): iter_bytes_spy = _stream_completion_with_spied_iter_bytes( model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0" From 5d5dc4523fb950e131a235bea9a4f767ba7e0e17 Mon Sep 17 00:00:00 2001 From: mateo Date: Wed, 19 Aug 2026 03:56:32 +0000 Subject: [PATCH 07/70] fix(cost): price streamed Messages usage via calculate_usage and the logging obj Streamed `/v1/messages` `usage.cost` disagreed with the cost the logging callback recorded in three ways: `input_tokens` was read as the whole prompt total, but Anthropic reports it excluding cache tokens, so the non-cached input went unbilled on cache hits; the `cache_creation` 5m/1h split was dropped, billing 1h writes at the 5m rate; and costing by model name alone ignored the deployment's custom pricing, so a negotiated discount still streamed sticker price. Anthropic usage now goes through `AnthropicConfig.calculate_usage`, the same transformation the non-streaming path uses, and the chunk is priced through the call's logging object when there is one so it inherits `custom_pricing`, `custom_llm_provider`, `base_model` and `router_model_id`, falling back to `completion_cost` by model name. `calculate_usage` only reads its `usage_object`, so it now takes a `Mapping`. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/anthropic/chat/transformation.py | 2 +- litellm/proxy/common_request_processing.py | 130 ++++++++++------ .../streaming_handler.py | 2 +- .../proxy/test_common_request_processing.py | 140 ++++++++++++++++++ 4 files changed, 226 insertions(+), 48 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index ef4ad7011c5..b4f040b0a8c 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2152,7 +2152,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): def calculate_usage( self, - usage_object: dict, + usage_object: Mapping[str, Any], reasoning_content: str | None, completion_response: dict | None = None, speed: str | None = None, diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index a0b69ecb0bf..f5299b273b4 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -158,7 +158,7 @@ ProxyRouteType: TypeAlias = Literal[ "acancel_run", "adelete_run", ] -from litellm.types.utils import ServerToolUse +from litellm.llms.anthropic.chat.transformation import AnthropicConfig # Type alias for streaming chunk serializer (chunk after hooks + cost injection -> wire format) StreamChunkSerializer = Callable[[Any], str] @@ -3321,7 +3321,9 @@ class ProxyBaseLLMRequestProcessing: str_so_far += str(chunk.get("content", "")) model_name = request_data.get("model", "") - chunk = ProxyBaseLLMRequestProcessing._process_chunk_with_cost_injection(chunk, model_name) + chunk = ProxyBaseLLMRequestProcessing._process_chunk_with_cost_injection( + chunk, model_name, request_data.get("litellm_logging_obj") + ) # Set before the yield: an async generator suspends at the yield, # so a GeneratorExit on client disconnect is raised there and any @@ -3418,20 +3420,28 @@ class ProxyBaseLLMRequestProcessing: @overload @staticmethod - def _process_chunk_with_cost_injection(chunk: bytes, model_name: str) -> bytes: ... + def _process_chunk_with_cost_injection( + chunk: bytes, model_name: str, litellm_logging_obj: LiteLLMLoggingObj | None = None + ) -> bytes: ... @overload @staticmethod - def _process_chunk_with_cost_injection(chunk: object, model_name: str) -> object: ... + def _process_chunk_with_cost_injection( + chunk: object, model_name: str, litellm_logging_obj: LiteLLMLoggingObj | None = None + ) -> object: ... @staticmethod - def _process_chunk_with_cost_injection(chunk: object, model_name: str) -> object: + def _process_chunk_with_cost_injection( + chunk: object, model_name: str, litellm_logging_obj: LiteLLMLoggingObj | None = None + ) -> object: """ Process a streaming chunk and inject cost information if enabled. Args: chunk: The streaming chunk (dict, str, bytes, or bytearray) model_name: Model name for cost calculation + litellm_logging_obj: The call's logging object, used to price the chunk with + the same custom/deployment pricing as the logging callback Returns: The processed chunk with cost information injected if applicable @@ -3441,21 +3451,27 @@ class ProxyBaseLLMRequestProcessing: try: if isinstance(chunk, dict): - maybe_modified: Final = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict(chunk, model_name) + maybe_modified: Final = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict( + chunk, model_name, litellm_logging_obj + ) if maybe_modified is not None: return maybe_modified elif isinstance(chunk, (bytes, bytearray)): try: s: Final = chunk.decode("utf-8") if s.endswith(("\n\n", "\r\n\r\n")): - maybe_mod = ProxyBaseLLMRequestProcessing._inject_cost_into_sse_frame_str(s, model_name) + maybe_mod = ProxyBaseLLMRequestProcessing._inject_cost_into_sse_frame_str( + s, model_name, litellm_logging_obj + ) if maybe_mod is not None: return maybe_mod.encode("utf-8") except Exception: pass elif isinstance(chunk, str): # Try to parse SSE frame and inject cost into the data line - maybe_mod = ProxyBaseLLMRequestProcessing._inject_cost_into_sse_frame_str(chunk, model_name) + maybe_mod = ProxyBaseLLMRequestProcessing._inject_cost_into_sse_frame_str( + chunk, model_name, litellm_logging_obj + ) if maybe_mod is not None: # Ensure trailing frame separator return maybe_mod if maybe_mod.endswith("\n\n") else (maybe_mod + "\n\n") @@ -3466,13 +3482,16 @@ class ProxyBaseLLMRequestProcessing: return chunk @staticmethod - def _inject_cost_into_sse_frame_str(frame_str: str, model_name: str) -> str | None: + def _inject_cost_into_sse_frame_str( + frame_str: str, model_name: str, litellm_logging_obj: LiteLLMLoggingObj | None = None + ) -> str | None: """ Inject cost information into an SSE frame string by modifying the JSON in the 'data:' line. Args: frame_str: SSE frame string that may contain multiple lines model_name: Model name for cost calculation + litellm_logging_obj: The call's logging object, forwarded for pricing Returns: Modified SSE frame string with cost injected, or None if no modification needed @@ -3486,7 +3505,9 @@ class ProxyBaseLLMRequestProcessing: json_part = stripped_ln.split("data:", 1)[1].strip() if json_part and json_part != "[DONE]": obj = json.loads(json_part) - maybe_modified = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict(obj, model_name) + maybe_modified = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict( + obj, model_name, litellm_logging_obj + ) if maybe_modified is not None: lines[idx] = "data: " + safe_dumps(maybe_modified) + ("\r" if ln.endswith("\r") else "") return "\n".join(lines) @@ -3494,34 +3515,6 @@ class ProxyBaseLLMRequestProcessing: except Exception: return None - @staticmethod - def _anthropic_stream_usage_kwargs(usage: Mapping[str, Any]) -> Mapping[str, Any]: - prompt_tokens: Final = int(usage.get("input_tokens", 0) or 0) - completion_tokens: Final = int(usage.get("output_tokens", 0) or 0) - total_tokens: Final = int( - usage.get("total_tokens", prompt_tokens + completion_tokens) or (prompt_tokens + completion_tokens) - ) - web_search_requests: Final = usage.get("web_search_requests") - server_tool_use: Final = ( - ServerToolUse(web_search_requests=web_search_requests) if web_search_requests is not None else None - ) - return MappingProxyType( - { - key: value - for key, value in ( - ("prompt_tokens", prompt_tokens), - ("completion_tokens", completion_tokens), - ("total_tokens", total_tokens), - ("completion_tokens_details", usage.get("completion_tokens_details")), - ("prompt_tokens_details", usage.get("prompt_tokens_details")), - ("cache_creation_input_tokens", usage.get("cache_creation_input_tokens")), - ("cache_read_input_tokens", usage.get("cache_read_input_tokens")), - ("server_tool_use", server_tool_use), - ) - if value is not None - } - ) - @staticmethod def _openai_stream_usage_kwargs(usage: Mapping[str, Any]) -> Mapping[str, Any]: prompt_tokens: Final = int(usage.get("prompt_tokens", 0) or 0) @@ -3544,11 +3537,19 @@ class ProxyBaseLLMRequestProcessing: ) @staticmethod - def _stream_usage_kwargs_for_event(obj: Mapping[str, object], usage: Mapping[str, Any]) -> Mapping[str, Any] | None: + def _stream_usage_for_event(obj: Mapping[str, object], usage: Mapping[str, Any]) -> Usage | None: + """ + Build the ``Usage`` to price a streamed usage event. + + Anthropic goes through ``AnthropicConfig.calculate_usage``, the same transformation + the non-streaming path uses, so ``prompt_tokens`` is the full input total and the + cache read plus 5m/1h cache creation split land in ``prompt_tokens_details`` where + the pricer looks for them. + """ if obj.get("type") == "message_delta": - return ProxyBaseLLMRequestProcessing._anthropic_stream_usage_kwargs(usage) + return AnthropicConfig().calculate_usage(usage_object=usage, reasoning_content=None) if obj.get("object") == "chat.completion.chunk": - return ProxyBaseLLMRequestProcessing._openai_stream_usage_kwargs(usage) + return Usage(**ProxyBaseLLMRequestProcessing._openai_stream_usage_kwargs(usage)) return None @staticmethod @@ -3563,7 +3564,41 @@ class ProxyBaseLLMRequestProcessing: return None @staticmethod - def _inject_cost_into_usage_dict(obj: dict, model_name: str) -> dict | None: + def _logging_obj_cost_or_none( + model_response: ModelResponse, litellm_logging_obj: LiteLLMLoggingObj + ) -> float | None: + try: + cost: Final = litellm_logging_obj._response_cost_calculator(result=model_response) # pyright: ignore[reportPrivateUsage] # reuse the call's own cost calc for pricing parity with the logging callback + except Exception: + return None + return float(cost) if isinstance(cost, (int, float)) and not isinstance(cost, bool) else None + + @staticmethod + def _streamed_usage_cost( + model_response: ModelResponse, + model_name: str, + service_tier: str | None, + litellm_logging_obj: LiteLLMLoggingObj | None, + ) -> float | None: + """ + Price a streamed usage response through the call's logging object when there is one, + so the streamed cost picks up the same custom/deployment pricing (``custom_pricing``, + ``custom_llm_provider``, ``base_model``, ``router_model_id``) as the logging callback + rather than the model's sticker price; fall back to ``completion_cost`` by model name. + """ + cost_from_logging_obj: Final = ( + ProxyBaseLLMRequestProcessing._logging_obj_cost_or_none(model_response, litellm_logging_obj) + if litellm_logging_obj is not None + else None + ) + if cost_from_logging_obj is not None: + return cost_from_logging_obj + return ProxyBaseLLMRequestProcessing._completion_cost_or_none(model_response, model_name, service_tier) + + @staticmethod + def _inject_cost_into_usage_dict( + obj: dict, model_name: str, litellm_logging_obj: LiteLLMLoggingObj | None = None + ) -> dict | None: """ Inject cost information into the usage object of a streamed usage event (Anthropic ``message_delta`` or OpenAI ``chat.completion.chunk``). @@ -3571,6 +3606,8 @@ class ProxyBaseLLMRequestProcessing: Args: obj: Dictionary containing the SSE event data model_name: Model name for cost calculation + litellm_logging_obj: The call's logging object, used so the injected cost + matches the cost the logging callback records Returns: Modified dictionary with cost injected, or None if no modification needed @@ -3578,14 +3615,15 @@ class ProxyBaseLLMRequestProcessing: usage: Final = obj.get("usage") if not isinstance(usage, dict): return None - usage_kwargs: Final = ProxyBaseLLMRequestProcessing._stream_usage_kwargs_for_event(obj, usage) - if usage_kwargs is None: + stream_usage: Final = ProxyBaseLLMRequestProcessing._stream_usage_for_event(obj, usage) + if stream_usage is None: return None service_tier: Final = obj.get("service_tier") - cost_val: Final = ProxyBaseLLMRequestProcessing._completion_cost_or_none( - ModelResponse(usage=Usage(**usage_kwargs)), + cost_val: Final = ProxyBaseLLMRequestProcessing._streamed_usage_cost( + ModelResponse(usage=stream_usage), model_name, service_tier if isinstance(service_tier, str) else None, + litellm_logging_obj, ) if cost_val is None: return None diff --git a/litellm/proxy/pass_through_endpoints/streaming_handler.py b/litellm/proxy/pass_through_endpoints/streaming_handler.py index 697eb7b96eb..b71622fc33d 100644 --- a/litellm/proxy/pass_through_endpoints/streaming_handler.py +++ b/litellm/proxy/pass_through_endpoints/streaming_handler.py @@ -106,7 +106,7 @@ class PassThroughStreamingHandler: ) # rebind-ok: SSE frame reassembly buffer across transport chunks if complete_frames: yield ProxyBaseLLMRequestProcessing._process_chunk_with_cost_injection( - complete_frames, resolved_model_name + complete_frames, resolved_model_name, litellm_logging_obj ) if pending: yield pending diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 355c6d27eb2..083982778b9 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -6082,6 +6082,121 @@ class TestInjectCostIntoUsageDict: injected = json.loads(result.split("\n")[0].split("data:", 1)[1].strip()) assert injected["usage"]["cost"] == pytest.approx(self._expected_cost("gpt-4o-mini", 11, 4)) + def test_message_delta_cost_charges_the_non_cached_input_tokens(self): + """Anthropic reports ``input_tokens`` excluding cache tokens, so reading it as the whole + prompt total drops the non-cached input from the bill on every cache hit.""" + model = "claude-haiku-4-5" + pricing = litellm.model_cost[model] + event = { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": { + "input_tokens": 14, + "output_tokens": 8, + "cache_read_input_tokens": 3202, + "cache_creation_input_tokens": 0, + }, + } + + result = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict(event, model) + + assert result is not None + expected = ( + 14 * pricing["input_cost_per_token"] + + 3202 * pricing["cache_read_input_token_cost"] + + 8 * pricing["output_cost_per_token"] + ) + dropped_input = expected - 14 * pricing["input_cost_per_token"] + assert result["usage"]["cost"] == pytest.approx(expected) + assert result["usage"]["cost"] > dropped_input + + def test_message_delta_prices_1h_cache_creation_above_the_5m_rate(self): + """The ``cache_creation`` 5m/1h split has to survive into ``prompt_tokens_details``, + otherwise a 1h write is billed at the cheaper 5m rate.""" + model = "claude-haiku-4-5" + pricing = litellm.model_cost[model] + event = { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": { + "input_tokens": 14, + "output_tokens": 8, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 2000, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 2000}, + }, + } + + result = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict(event, model) + + assert result is not None + base = 14 * pricing["input_cost_per_token"] + 8 * pricing["output_cost_per_token"] + expected_1h = base + 2000 * pricing["cache_creation_input_token_cost_above_1hr"] + flat_5m = base + 2000 * pricing["cache_creation_input_token_cost"] + assert expected_1h != pytest.approx(flat_5m) + assert result["usage"]["cost"] == pytest.approx(expected_1h) + + def test_message_delta_prices_through_the_logging_obj_so_custom_pricing_applies(self): + """Costing by model name alone yields sticker price, so a deployment with a negotiated + discount streamed a ``usage.cost`` that disagreed with the callback's ``response_cost``.""" + + class _StubLoggingObj: + def __init__(self, cost): + self._cost = cost + self.captured_result = None + + def _response_cost_calculator(self, result): + self.captured_result = result + return self._cost + + model = "claude-haiku-4-5" + discounted_cost = 0.00099 + stub = _StubLoggingObj(discounted_cost) + event = { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": { + "input_tokens": 14, + "output_tokens": 8, + "cache_read_input_tokens": 3202, + "cache_creation_input_tokens": 500, + "cache_creation": {"ephemeral_5m_input_tokens": 100, "ephemeral_1h_input_tokens": 400}, + }, + } + + result = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict(event, model, stub) + + assert result is not None + assert result["usage"]["cost"] == discounted_cost + assert result["usage"]["cost"] != pytest.approx(self._expected_cost(model, 14 + 500 + 3202, 8)) + usage = stub.captured_result.usage + assert usage.prompt_tokens == 14 + 500 + 3202 + details = usage.prompt_tokens_details.cache_creation_token_details + assert details.ephemeral_5m_input_tokens == 100 + assert details.ephemeral_1h_input_tokens == 400 + + def test_message_delta_falls_back_to_model_pricing_when_the_logging_obj_returns_no_cost(self): + class _StubLoggingObj: + def _response_cost_calculator(self, result): + return None + + model = "claude-haiku-4-5" + pricing = litellm.model_cost[model] + event = { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"input_tokens": 14, "output_tokens": 8, "cache_read_input_tokens": 3202}, + } + + result = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict(event, model, _StubLoggingObj()) + + assert result is not None + assert result["usage"]["cost"] == pytest.approx( + 14 * pricing["input_cost_per_token"] + + 3202 * pricing["cache_read_input_token_cost"] + + 8 * pricing["output_cost_per_token"] + ) + class TestProcessChunkWithCostInjection: def test_complete_usage_frame_chunk_is_injected(self, monkeypatch): @@ -6116,6 +6231,31 @@ class TestProcessChunkWithCostInjection: assert ProxyBaseLLMRequestProcessing._process_chunk_with_cost_injection(chunk, "gpt-4o-mini") == chunk + def test_message_delta_frame_is_priced_with_the_logging_obj(self, monkeypatch): + """Pins that the logging object reaches the pricer through the byte-frame entry point, + which is how the proxy actually calls this on a streamed Messages API request.""" + monkeypatch.setattr(litellm, "include_cost_in_streaming_usage", True) + + class _StubLoggingObj: + def _response_cost_calculator(self, result): + return 0.00042 + + chunk = ( + b"event: message_delta\n" + b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},' + b'"usage":{"input_tokens":14,"output_tokens":8,"cache_read_input_tokens":3202}}\n\n' + ) + + result = ProxyBaseLLMRequestProcessing._process_chunk_with_cost_injection( + chunk, "claude-haiku-4-5", _StubLoggingObj() + ) + + assert result != chunk + data_line = next(ln for ln in result.decode("utf-8").splitlines() if ln.startswith("data:")) + payload = json.loads(data_line.split("data:", 1)[1].strip()) + assert payload["usage"]["cost"] == 0.00042 + assert payload["usage"]["cache_read_input_tokens"] == 3202 + # --------------------------------------------------------------------------- # SSE keepalive during the time-to-first-token (issue #34819) From 6f4844bfd9dd50901e3e4542f296602c9c8c9e56 Mon Sep 17 00:00:00 2001 From: mateo Date: Wed, 19 Aug 2026 03:58:24 +0000 Subject: [PATCH 08/70] refactor(cost): trim streamed cost helper docstrings to the non-obvious bits Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/common_request_processing.py | 24 ++++++---------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index f5299b273b4..d533df8616d 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -3440,8 +3440,7 @@ class ProxyBaseLLMRequestProcessing: Args: chunk: The streaming chunk (dict, str, bytes, or bytearray) model_name: Model name for cost calculation - litellm_logging_obj: The call's logging object, used to price the chunk with - the same custom/deployment pricing as the logging callback + litellm_logging_obj: The call's logging object, used for pricing Returns: The processed chunk with cost information injected if applicable @@ -3538,14 +3537,8 @@ class ProxyBaseLLMRequestProcessing: @staticmethod def _stream_usage_for_event(obj: Mapping[str, object], usage: Mapping[str, Any]) -> Usage | None: - """ - Build the ``Usage`` to price a streamed usage event. - - Anthropic goes through ``AnthropicConfig.calculate_usage``, the same transformation - the non-streaming path uses, so ``prompt_tokens`` is the full input total and the - cache read plus 5m/1h cache creation split land in ``prompt_tokens_details`` where - the pricer looks for them. - """ + # Anthropic reports input_tokens excluding cache tokens, so reuse the non-streaming + # transformation to total the prompt and keep the 5m/1h cache creation split if obj.get("type") == "message_delta": return AnthropicConfig().calculate_usage(usage_object=usage, reasoning_content=None) if obj.get("object") == "chat.completion.chunk": @@ -3580,12 +3573,8 @@ class ProxyBaseLLMRequestProcessing: service_tier: str | None, litellm_logging_obj: LiteLLMLoggingObj | None, ) -> float | None: - """ - Price a streamed usage response through the call's logging object when there is one, - so the streamed cost picks up the same custom/deployment pricing (``custom_pricing``, - ``custom_llm_provider``, ``base_model``, ``router_model_id``) as the logging callback - rather than the model's sticker price; fall back to ``completion_cost`` by model name. - """ + # Pricing via the logging object inherits the deployment's custom pricing, so the + # streamed cost matches what the logging callback records instead of sticker price cost_from_logging_obj: Final = ( ProxyBaseLLMRequestProcessing._logging_obj_cost_or_none(model_response, litellm_logging_obj) if litellm_logging_obj is not None @@ -3606,8 +3595,7 @@ class ProxyBaseLLMRequestProcessing: Args: obj: Dictionary containing the SSE event data model_name: Model name for cost calculation - litellm_logging_obj: The call's logging object, used so the injected cost - matches the cost the logging callback records + litellm_logging_obj: The call's logging object, used for pricing Returns: Modified dictionary with cost injected, or None if no modification needed From aa832d81e91bb17e0f5ab081431cb03d2ef4083f Mon Sep 17 00:00:00 2001 From: ljogeiger <39981740+ljogeiger@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:08:42 +0000 Subject: [PATCH 09/70] fix(vertex_ai): only fall back to a placeholder thought signature on the first parallel function call Gemini returns a thoughtSignature on the first function call of a parallel batch and leaves the siblings bare. When replaying that assistant turn, litellm gave every unsigned call the skip_thought_signature_validator placeholder, so a three-call turn went back with three signatures where Gemini had produced one. Keep the placeholder for the first call only and forward the siblings with whatever signature they actually carry, which is usually none. --- .../prompt_templates/factory.py | 23 ++-- .../test_vertex_ai_gemini_transformation.py | 115 ++++++++++++++++++ 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 0ed15c43ccf..0a9d7c427b4 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1200,13 +1200,14 @@ def _encode_tool_call_id_with_signature(tool_call_id: str, thought_signature: st return tool_call_id -def _get_thought_signature_from_tool(tool: dict, model: str | None = None) -> str | None: +def _get_thought_signature_from_tool(tool: dict) -> str | None: """Extract thought signature from tool call's provider_specific_fields. If not provided try to extract thought signature from tool call id Checks both tool.provider_specific_fields and tool.function.provider_specific_fields. - If no signature is found and model is gemini-3, returns a dummy signature. + Returns None when the tool call carries no signature; callers decide whether a + placeholder signature is needed. """ # First check tool's provider_specific_fields provider_fields: Final = tool.get("provider_specific_fields") or {} @@ -1236,13 +1237,6 @@ def _get_thought_signature_from_tool(tool: dict, model: str | None = None) -> st if len(parts) == 2: _, signature = parts return signature - # If no signature found and model is gemini-3, return dummy signature - from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexGeminiConfig, - ) - - if model and VertexGeminiConfig._is_gemini_3_or_newer(model): - return _get_dummy_thought_signature() return None @@ -1312,8 +1306,10 @@ def convert_to_gemini_tool_call_invoke( VertexGeminiConfig, ) + needs_dummy_signature: Final = model is not None and VertexGeminiConfig._is_gemini_3_or_newer(model) + if tool_calls is not None: - for idx, tool in enumerate(tool_calls): + for tool in tool_calls: if "function" in tool: gemini_function_call: VertexFunctionCall | None = _gemini_tool_call_invoke_helper( function_call_params=tool["function"], @@ -1321,7 +1317,10 @@ def convert_to_gemini_tool_call_invoke( ) if gemini_function_call is not None: part_dict: VertexPartType = {"function_call": gemini_function_call} - thought_signature = _get_thought_signature_from_tool(dict(tool), model=model) + thought_signature = _get_thought_signature_from_tool(dict(tool)) + is_first_function_call = len(_parts_list) == 0 + if not thought_signature and is_first_function_call and needs_dummy_signature: + thought_signature = _get_dummy_thought_signature() if thought_signature: part_dict["thoughtSignature"] = thought_signature @@ -1344,7 +1343,7 @@ def convert_to_gemini_tool_call_invoke( thought_signature = provider_fields.get("thought_signature") # If no signature found and model is gemini-3, use dummy signature - if not thought_signature and model and VertexGeminiConfig._is_gemini_3_or_newer(model): + if not thought_signature and needs_dummy_signature: thought_signature = _get_dummy_thought_signature() if thought_signature: diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index 8ee8186f6bb..28c551ab824 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -1,3 +1,5 @@ +import base64 + from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_gemini_tool_call_result, ) @@ -784,6 +786,119 @@ def test_dummy_signature_with_function_call_mode(): assert gemini_parts[0]["thoughtSignature"] == expected_dummy +def _parallel_tool_calls(*signatures): + return [ + { + "id": f"call_{idx}", + "type": "function", + "function": { + "name": f"tool_{idx}", + "arguments": '{"location": "Paris"}', + **( + {"provider_specific_fields": {"thought_signature": signature}} + if signature is not None + else {} + ), + }, + "index": idx, + } + for idx, signature in enumerate(signatures) + ] + + +REAL_THOUGHT_SIGNATURE = "Co4CAdHtim/rWgXbz2Ghp4tShzLeMASrPw6JJyYIC3cbVyZnKzU3uv8/wVzyS2sKRPL2m8QQHHXbNQhEEz500G7n" + + +def test_dummy_signature_only_on_first_parallel_tool_call(): + """Gemini only returns a thought signature on the first of N parallel function calls. + + The sibling calls carry no signature, so replaying them must not fabricate one. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_invoke, + ) + + gemini_parts = convert_to_gemini_tool_call_invoke( + { + "role": "assistant", + "content": None, + "tool_calls": _parallel_tool_calls(None, None, None), + }, + model="gemini-3-pro-preview", + ) + + expected_dummy = base64.b64encode(b"skip_thought_signature_validator").decode( + "utf-8" + ) + assert len(gemini_parts) == 3 + assert gemini_parts[0]["thoughtSignature"] == expected_dummy + assert "thoughtSignature" not in gemini_parts[1] + assert "thoughtSignature" not in gemini_parts[2] + + +def test_real_signature_on_first_parallel_tool_call_leaves_siblings_empty(): + """The real signature from Gemini rides on the first call; siblings stay signature-free.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_invoke, + ) + + gemini_parts = convert_to_gemini_tool_call_invoke( + { + "role": "assistant", + "content": None, + "tool_calls": _parallel_tool_calls(REAL_THOUGHT_SIGNATURE, None, None), + }, + model="gemini-3-pro-preview", + ) + + assert len(gemini_parts) == 3 + assert gemini_parts[0]["thoughtSignature"] == REAL_THOUGHT_SIGNATURE + assert "thoughtSignature" not in gemini_parts[1] + assert "thoughtSignature" not in gemini_parts[2] + + +def test_real_signature_on_later_parallel_tool_call_is_preserved(): + """A signature attached to a non-first call is still forwarded as-is.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_invoke, + ) + + gemini_parts = convert_to_gemini_tool_call_invoke( + { + "role": "assistant", + "content": None, + "tool_calls": _parallel_tool_calls(None, REAL_THOUGHT_SIGNATURE), + }, + model="gemini-3-pro-preview", + ) + + expected_dummy = base64.b64encode(b"skip_thought_signature_validator").decode( + "utf-8" + ) + assert len(gemini_parts) == 2 + assert gemini_parts[0]["thoughtSignature"] == expected_dummy + assert gemini_parts[1]["thoughtSignature"] == REAL_THOUGHT_SIGNATURE + + +def test_no_signatures_on_parallel_tool_calls_for_gemini_2_5(): + """Non-gemini-3 models never get a placeholder signature, on any call.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_invoke, + ) + + gemini_parts = convert_to_gemini_tool_call_invoke( + { + "role": "assistant", + "content": None, + "tool_calls": _parallel_tool_calls(None, None), + }, + model="gemini-2.5-flash", + ) + + assert len(gemini_parts) == 2 + assert all("thoughtSignature" not in part for part in gemini_parts) + + # Tests for media_resolution (detail parameter) handling - Issue #17084 class TestMediaResolution: """Tests for media_resolution handling in Gemini 2.x models""" From 677ef1e317080c20aec895a7ed25c058a1e18582 Mon Sep 17 00:00:00 2001 From: ljogeiger <39981740+ljogeiger@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:21:31 +0000 Subject: [PATCH 10/70] docs(vertex_ai): drop stale note about the removed model argument --- litellm/llms/vertex_ai/gemini/transformation.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index f2d318a9ffd..11c026010ee 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -645,10 +645,9 @@ def _collect_tool_call_thought_signatures( the text part as well would send two copies and double-bill the previous turn's reasoning tokens on gemini-3 and newer models. - Detection deliberately calls _get_thought_signature_from_tool without the - model argument: with a gemini-3 model that helper synthesizes a dummy - signature for unsigned tool calls, which must not suppress a real - text-part signature (e.g. replaying gemini-2.5 history to a newer model). + Only real signatures count here; a synthesized placeholder must not + suppress a genuine text-part signature (e.g. replaying gemini-2.5 history + to a newer model). """ signatures: tuple[str, ...] = () From d5af42717e9713771b8a014455b79d22b0756754 Mon Sep 17 00:00:00 2001 From: ljogeiger <39981740+ljogeiger@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:32:24 +0000 Subject: [PATCH 11/70] test(vertex_ai): cover id-embedded, tool-level, and end-to-end parallel signature replay --- .../test_vertex_ai_gemini_transformation.py | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index 28c551ab824..562f27c11cf 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -806,6 +806,27 @@ def _parallel_tool_calls(*signatures): ] +def _parallel_tool_calls_signed_via_id(*signatures): + """Parallel tool calls in the shape LiteLLM actually hands back to clients. + + The signature rides in the tool call id behind __thought__, which is what an + OpenAI-format client echoes back on the next turn. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + _encode_tool_call_id_with_signature, + ) + + return [ + { + "id": _encode_tool_call_id_with_signature(f"call_{idx}", signature), + "type": "function", + "function": {"name": f"tool_{idx}", "arguments": '{"location": "Paris"}'}, + "index": idx, + } + for idx, signature in enumerate(signatures) + ] + + REAL_THOUGHT_SIGNATURE = "Co4CAdHtim/rWgXbz2Ghp4tShzLeMASrPw6JJyYIC3cbVyZnKzU3uv8/wVzyS2sKRPL2m8QQHHXbNQhEEz500G7n" @@ -899,6 +920,166 @@ def test_no_signatures_on_parallel_tool_calls_for_gemini_2_5(): assert all("thoughtSignature" not in part for part in gemini_parts) +def test_signature_embedded_in_tool_call_id_only_on_first_parallel_call(): + """The production shape: the signature arrives inside the first call's id, siblings have bare ids.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_invoke, + ) + + gemini_parts = convert_to_gemini_tool_call_invoke( + { + "role": "assistant", + "content": None, + "tool_calls": _parallel_tool_calls_signed_via_id( + REAL_THOUGHT_SIGNATURE, None, None + ), + }, + model="gemini-3-pro-preview", + ) + + assert len(gemini_parts) == 3 + assert gemini_parts[0]["thoughtSignature"] == REAL_THOUGHT_SIGNATURE + assert "thoughtSignature" not in gemini_parts[1] + assert "thoughtSignature" not in gemini_parts[2] + + +def test_tool_level_provider_specific_fields_signature_leaves_siblings_empty(): + """A signature on the tool call itself, rather than on its function, behaves the same way.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_invoke, + ) + + tool_calls = _parallel_tool_calls(None, None) + tool_calls[0]["provider_specific_fields"] = { + "thought_signature": REAL_THOUGHT_SIGNATURE + } + + gemini_parts = convert_to_gemini_tool_call_invoke( + {"role": "assistant", "content": None, "tool_calls": tool_calls}, + model="gemini-3-pro-preview", + ) + + assert len(gemini_parts) == 2 + assert gemini_parts[0]["thoughtSignature"] == REAL_THOUGHT_SIGNATURE + assert "thoughtSignature" not in gemini_parts[1] + + +def test_placeholder_lands_on_first_emitted_part_not_first_tool_call_entry(): + """A non-function entry (e.g. an OpenAI custom tool call) emits no part, so it must not + consume the one placeholder slot and leave the real first function call bare.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + _get_dummy_thought_signature, + convert_to_gemini_tool_call_invoke, + ) + + tool_calls = [ + {"id": "call_custom", "type": "custom", "custom": {"name": "noop", "input": ""}} + ] + _parallel_tool_calls(None, None) + + gemini_parts = convert_to_gemini_tool_call_invoke( + {"role": "assistant", "content": None, "tool_calls": tool_calls}, + model="gemini-3-pro-preview", + ) + + assert len(gemini_parts) == 2 + assert gemini_parts[0]["thoughtSignature"] == _get_dummy_thought_signature() + assert "thoughtSignature" not in gemini_parts[1] + + +def test_no_placeholder_when_model_is_unknown(): + """Without a model there is nothing to prove the target needs a placeholder, so none is added.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_invoke, + ) + + gemini_parts = convert_to_gemini_tool_call_invoke( + { + "role": "assistant", + "content": None, + "tool_calls": _parallel_tool_calls(None, None), + }, + ) + + assert len(gemini_parts) == 2 + assert all("thoughtSignature" not in part for part in gemini_parts) + + +def test_real_signature_forwarded_to_gemini_2_5_without_placeholder_siblings(): + """Older models still receive a real signature that a client replays, and still get no placeholder.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + convert_to_gemini_tool_call_invoke, + ) + + gemini_parts = convert_to_gemini_tool_call_invoke( + { + "role": "assistant", + "content": None, + "tool_calls": _parallel_tool_calls(REAL_THOUGHT_SIGNATURE, None), + }, + model="gemini-2.5-flash", + ) + + assert len(gemini_parts) == 2 + assert gemini_parts[0]["thoughtSignature"] == REAL_THOUGHT_SIGNATURE + assert "thoughtSignature" not in gemini_parts[1] + + +def test_parallel_tool_call_history_replayed_through_full_message_conversion(): + """End to end through the message-history converter, the path a real /chat/completions replay takes.""" + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + messages = [ + {"role": "user", "content": "Weather in Paris, London and Tokyo?"}, + { + "role": "assistant", + "content": None, + "tool_calls": _parallel_tool_calls_signed_via_id( + REAL_THOUGHT_SIGNATURE, None, None + ), + }, + ] + + contents = _gemini_convert_messages_with_history( + messages=messages, model="gemini-3-pro-preview" + ) + + model_parts = contents[1]["parts"] + assert len(model_parts) == 3 + assert model_parts[0]["thoughtSignature"] == REAL_THOUGHT_SIGNATURE + assert "thoughtSignature" not in model_parts[1] + assert "thoughtSignature" not in model_parts[2] + + +def test_signed_text_part_survives_alongside_unsigned_parallel_tool_calls(): + """gemini-2.5 history with a signed text part and parallel unsigned calls: the text keeps its real + signature, only the first call gets the placeholder, and the siblings stay bare.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + _get_dummy_thought_signature, + ) + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + msg = { + "role": "assistant", + "content": "Checking all three cities.", + "provider_specific_fields": {"thought_signatures": ["real_25_signature"]}, + "tool_calls": _parallel_tool_calls(None, None, None), + } + + parts = _gemini_convert_messages_with_history( + messages=[msg], model="gemini-3-pro-preview" + )[0]["parts"] + + assert parts[0]["text"] == "Checking all three cities." + assert parts[0]["thoughtSignature"] == "real_25_signature" + assert parts[1]["thoughtSignature"] == _get_dummy_thought_signature() + assert "thoughtSignature" not in parts[2] + assert "thoughtSignature" not in parts[3] + + # Tests for media_resolution (detail parameter) handling - Issue #17084 class TestMediaResolution: """Tests for media_resolution handling in Gemini 2.x models""" From db50e123d5f23d475dab0bc62e33364ea19df817 Mon Sep 17 00:00:00 2001 From: ljogeiger <39981740+ljogeiger@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:42:24 +0000 Subject: [PATCH 12/70] test(vertex_ai): parametrize placeholder scoping across gemini-3 model variants --- .../test_vertex_ai_gemini_transformation.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index 562f27c11cf..9ebf6db11de 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -1,5 +1,7 @@ import base64 +import pytest + from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_gemini_tool_call_result, ) @@ -1052,6 +1054,40 @@ def test_parallel_tool_call_history_replayed_through_full_message_conversion(): assert "thoughtSignature" not in model_parts[2] +@pytest.mark.parametrize( + "model", + [ + "gemini-3-pro-preview", + "gemini-3-flash-preview", + "gemini-3.1-pro-preview", + "gemini-3.6-flash", + "gemini-3.7-flash", + "vertex_ai/gemini-3.7-flash", + "gemini/gemini-3.7-flash", + ], +) +def test_placeholder_scoped_to_first_call_across_gemini_3_variants(model): + """Every gemini-3 family member, bare or provider-prefixed, gets one placeholder at most.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + _get_dummy_thought_signature, + convert_to_gemini_tool_call_invoke, + ) + + gemini_parts = convert_to_gemini_tool_call_invoke( + { + "role": "assistant", + "content": None, + "tool_calls": _parallel_tool_calls(None, None, None), + }, + model=model, + ) + + assert len(gemini_parts) == 3 + assert gemini_parts[0]["thoughtSignature"] == _get_dummy_thought_signature() + assert "thoughtSignature" not in gemini_parts[1] + assert "thoughtSignature" not in gemini_parts[2] + + def test_signed_text_part_survives_alongside_unsigned_parallel_tool_calls(): """gemini-2.5 history with a signed text part and parallel unsigned calls: the text keeps its real signature, only the first call gets the placeholder, and the siblings stay bare.""" From 579291774b0a8b5e98c33140ae89fe60a30360b0 Mon Sep 17 00:00:00 2001 From: ljogeiger <39981740+ljogeiger@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:16:36 +0000 Subject: [PATCH 13/70] docs(vertex_ai): cite Google's thought signature rules for parallel calls Link the Gemini Enterprise Agent Platform docs at both places the behavior is decided. The docs state that only the first functionCall part of a parallel batch carries a thought_signature, and that setting skip_thought_signature_validator "should be a last resort as it will negatively impact model performance". --- .../litellm_core_utils/prompt_templates/factory.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 0a9d7c427b4..b676077ab0e 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1245,10 +1245,14 @@ def _get_dummy_thought_signature() -> str: This is used when transferring conversation history from older models (like gemini-2.5-flash) to gemini-3, which requires thought_signature - for strict validation. + for strict validation. Google documents it as a last resort that "will + negatively impact model performance", so callers must only fall back to it + when no real signature is available. + + See: + https://ai.google.dev/gemini-api/docs/thought-signatures#faqs + https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/thinking/thought-signatures """ - # Return a base64-encoded dummy signature string - # Below dummy signature is recommended by google - https://ai.google.dev/gemini-api/docs/thought-signatures#faqs dummy_data: Final = b"skip_thought_signature_validator" return base64.b64encode(dummy_data).decode("utf-8") @@ -1318,6 +1322,9 @@ def convert_to_gemini_tool_call_invoke( if gemini_function_call is not None: part_dict: VertexPartType = {"function_call": gemini_function_call} thought_signature = _get_thought_signature_from_tool(dict(tool)) + # Gemini signs only the first functionCall part of a parallel batch, so scope the + # placeholder fallback to that part instead of fabricating one per sibling call: + # https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/thinking/thought-signatures#parallel_function_calling_example is_first_function_call = len(_parts_list) == 0 if not thought_signature and is_first_function_call and needs_dummy_signature: thought_signature = _get_dummy_thought_signature() From a5ad22b8a3f734b09ce5e05a68dab5c5205907a4 Mon Sep 17 00:00:00 2001 From: ljogeiger <39981740+ljogeiger@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:16:36 +0000 Subject: [PATCH 14/70] test(vertex_ai): cover gemini-3.5-flash and drop assertion-echoing docstrings Add gemini-3.5-flash to the placeholder-scoping matrix and a regression test that a natively signed parallel turn replays with no skip_thought_signature_validator anywhere in the payload, the shape that was producing empty text responses on 3.5. Hoist the repeated placeholder expression into one constant and rewrite the docstrings that restated their own assertions to say why the case matters instead. --- .../test_vertex_ai_gemini_transformation.py | 83 +++++++++++++------ 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index 9ebf6db11de..8c1de12e7d9 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -830,13 +830,14 @@ def _parallel_tool_calls_signed_via_id(*signatures): REAL_THOUGHT_SIGNATURE = "Co4CAdHtim/rWgXbz2Ghp4tShzLeMASrPw6JJyYIC3cbVyZnKzU3uv8/wVzyS2sKRPL2m8QQHHXbNQhEEz500G7n" +PLACEHOLDER_SIGNATURE = base64.b64encode(b"skip_thought_signature_validator").decode( + "utf-8" +) def test_dummy_signature_only_on_first_parallel_tool_call(): - """Gemini only returns a thought signature on the first of N parallel function calls. - - The sibling calls carry no signature, so replaying them must not fabricate one. - """ + """Google documents the placeholder as a last resort that degrades quality, so an unsigned + parallel turn replayed to gemini-3 gets a budget of exactly one.""" from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_gemini_tool_call_invoke, ) @@ -850,17 +851,15 @@ def test_dummy_signature_only_on_first_parallel_tool_call(): model="gemini-3-pro-preview", ) - expected_dummy = base64.b64encode(b"skip_thought_signature_validator").decode( - "utf-8" - ) assert len(gemini_parts) == 3 - assert gemini_parts[0]["thoughtSignature"] == expected_dummy + assert gemini_parts[0]["thoughtSignature"] == PLACEHOLDER_SIGNATURE assert "thoughtSignature" not in gemini_parts[1] assert "thoughtSignature" not in gemini_parts[2] def test_real_signature_on_first_parallel_tool_call_leaves_siblings_empty(): - """The real signature from Gemini rides on the first call; siblings stay signature-free.""" + """Gemini signs only the first of N parallel function calls, so a faithful replay has + nothing to attach to the siblings.""" from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_gemini_tool_call_invoke, ) @@ -881,7 +880,8 @@ def test_real_signature_on_first_parallel_tool_call_leaves_siblings_empty(): def test_real_signature_on_later_parallel_tool_call_is_preserved(): - """A signature attached to a non-first call is still forwarded as-is.""" + """Clients may reorder or drop calls, so a signature that lands on a non-first call is + still the model's own and must survive the round trip.""" from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_gemini_tool_call_invoke, ) @@ -895,11 +895,8 @@ def test_real_signature_on_later_parallel_tool_call_is_preserved(): model="gemini-3-pro-preview", ) - expected_dummy = base64.b64encode(b"skip_thought_signature_validator").decode( - "utf-8" - ) assert len(gemini_parts) == 2 - assert gemini_parts[0]["thoughtSignature"] == expected_dummy + assert gemini_parts[0]["thoughtSignature"] == PLACEHOLDER_SIGNATURE assert gemini_parts[1]["thoughtSignature"] == REAL_THOUGHT_SIGNATURE @@ -970,7 +967,6 @@ def test_placeholder_lands_on_first_emitted_part_not_first_tool_call_entry(): """A non-function entry (e.g. an OpenAI custom tool call) emits no part, so it must not consume the one placeholder slot and leave the real first function call bare.""" from litellm.litellm_core_utils.prompt_templates.factory import ( - _get_dummy_thought_signature, convert_to_gemini_tool_call_invoke, ) @@ -984,7 +980,7 @@ def test_placeholder_lands_on_first_emitted_part_not_first_tool_call_entry(): ) assert len(gemini_parts) == 2 - assert gemini_parts[0]["thoughtSignature"] == _get_dummy_thought_signature() + assert gemini_parts[0]["thoughtSignature"] == PLACEHOLDER_SIGNATURE assert "thoughtSignature" not in gemini_parts[1] @@ -1054,22 +1050,62 @@ def test_parallel_tool_call_history_replayed_through_full_message_conversion(): assert "thoughtSignature" not in model_parts[2] +@pytest.mark.parametrize( + "model", + ["gemini-3.5-flash", "vertex_ai/gemini-3.5-flash", "gemini/gemini-3.5-flash"], +) +def test_natively_signed_parallel_turn_never_carries_a_placeholder(model): + """A native gemini-3.5 parallel turn replays with zero skip_thought_signature_validator parts. + + Fabricating the placeholder alongside a real signature is what produced empty text responses + on gemini-3.5 parallel function calling, so the whole payload has to stay placeholder-free. + """ + import json + + from litellm.llms.vertex_ai.gemini.transformation import ( + _gemini_convert_messages_with_history, + ) + + messages = [ + {"role": "user", "content": "Weather in Paris, London and Tokyo?"}, + { + "role": "assistant", + "content": None, + "tool_calls": _parallel_tool_calls_signed_via_id( + REAL_THOUGHT_SIGNATURE, None, None + ), + }, + ] + + contents = _gemini_convert_messages_with_history(messages=messages, model=model) + + model_parts = contents[1]["parts"] + assert len(model_parts) == 3 + assert model_parts[0]["thoughtSignature"] == REAL_THOUGHT_SIGNATURE + assert "thoughtSignature" not in model_parts[1] + assert "thoughtSignature" not in model_parts[2] + assert PLACEHOLDER_SIGNATURE not in json.dumps(contents) + + @pytest.mark.parametrize( "model", [ "gemini-3-pro-preview", "gemini-3-flash-preview", "gemini-3.1-pro-preview", + "gemini-3.5-flash", "gemini-3.6-flash", "gemini-3.7-flash", + "vertex_ai/gemini-3.5-flash", "vertex_ai/gemini-3.7-flash", + "gemini/gemini-3.5-flash", "gemini/gemini-3.7-flash", ], ) def test_placeholder_scoped_to_first_call_across_gemini_3_variants(model): - """Every gemini-3 family member, bare or provider-prefixed, gets one placeholder at most.""" + """The gemini-3 gate is a substring match, so every family member and prefix form has to + land on the same one-placeholder budget rather than only the versions we happened to try.""" from litellm.litellm_core_utils.prompt_templates.factory import ( - _get_dummy_thought_signature, convert_to_gemini_tool_call_invoke, ) @@ -1083,17 +1119,14 @@ def test_placeholder_scoped_to_first_call_across_gemini_3_variants(model): ) assert len(gemini_parts) == 3 - assert gemini_parts[0]["thoughtSignature"] == _get_dummy_thought_signature() + assert gemini_parts[0]["thoughtSignature"] == PLACEHOLDER_SIGNATURE assert "thoughtSignature" not in gemini_parts[1] assert "thoughtSignature" not in gemini_parts[2] def test_signed_text_part_survives_alongside_unsigned_parallel_tool_calls(): - """gemini-2.5 history with a signed text part and parallel unsigned calls: the text keeps its real - signature, only the first call gets the placeholder, and the siblings stay bare.""" - from litellm.litellm_core_utils.prompt_templates.factory import ( - _get_dummy_thought_signature, - ) + """Text-part and function-call signatures are collected by separate code paths, so scoping the + placeholder must not disturb a real signature that arrived on the text part.""" from litellm.llms.vertex_ai.gemini.transformation import ( _gemini_convert_messages_with_history, ) @@ -1111,7 +1144,7 @@ def test_signed_text_part_survives_alongside_unsigned_parallel_tool_calls(): assert parts[0]["text"] == "Checking all three cities." assert parts[0]["thoughtSignature"] == "real_25_signature" - assert parts[1]["thoughtSignature"] == _get_dummy_thought_signature() + assert parts[1]["thoughtSignature"] == PLACEHOLDER_SIGNATURE assert "thoughtSignature" not in parts[2] assert "thoughtSignature" not in parts[3] From 8b7c801d61be5e4d02127ff6e86d743b157678f9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:03:35 -0700 Subject: [PATCH 15/70] test(e2e): pin openai_passthrough routing, cost logging, and file list isolation Five e2e tests over routes a customer drives through the gateway, each one pinning a fix that currently has no live coverage. The dedicated /openai_passthrough prefix used to be swallowed by the provider-scoped /{provider}/v1/files and /{provider}/v1/batches routes, which bound "openai_passthrough" as a provider name and failed inside the gateway before ever reaching OpenAI. Two tests now upload a file and list batches through that prefix and assert OpenAI's own objects come back. Streamed /openai_passthrough/v1/responses and /openai_passthrough/v1/embeddings are relayed to OpenAI but still have to be costed, since the customer budgets against this traffic. Both used to land a row the gateway could not use: the streamed responses call logged a zero-cost row under a random id, and embeddings wrote no row at all. Each test now reconciles the logged spend and token counts against the response the caller was actually served. GET /v1/files narrowed its data to the caller's own rows but left first_id and last_id addressing the shared provider account's page, handing any caller raw provider file ids belonging to other tenants. The new test asserts both cursors address rows in the page the caller can see. ResourceManager.defer now accepts any callable rather than one returning None, so a delete that answers with a response model can be deferred as-is. --- tests/e2e/batches/batch_client.py | 7 + tests/e2e/batches/test_batches_e2e.py | 34 +++++ .../coverage_registry/llm_conversational.yaml | 1 + .../llm_nonconversational.yaml | 4 + tests/e2e/lifecycle.py | 9 +- .../e2e/llm_translation/passthrough_client.py | 132 +++++++++++++++++- .../llm_translation/test_passthrough_e2e.py | 124 +++++++++++++++- 7 files changed, 305 insertions(+), 6 deletions(-) diff --git a/tests/e2e/batches/batch_client.py b/tests/e2e/batches/batch_client.py index 5cc5d1dae3b..968a357e8af 100644 --- a/tests/e2e/batches/batch_client.py +++ b/tests/e2e/batches/batch_client.py @@ -40,8 +40,15 @@ class FileObject(BaseModel): class FileList(BaseModel): + """GET /v1/files page. The cursors are modelled because they are part of the + page's isolation contract: they must address rows in `data`, never rows the + caller was not allowed to see.""" + object: str | None = None data: list[FileObject] = [] + first_id: str | None = None + last_id: str | None = None + has_more: bool | None = None class BatchObject(BaseModel): diff --git a/tests/e2e/batches/test_batches_e2e.py b/tests/e2e/batches/test_batches_e2e.py index 53bf9739983..536bc113a25 100644 --- a/tests/e2e/batches/test_batches_e2e.py +++ b/tests/e2e/batches/test_batches_e2e.py @@ -572,6 +572,40 @@ class TestOpenAIFiles: f"listed file must round-trip the upload purpose, got {match.purpose!r}" ) + @pytest.mark.covers( + "llm.files.openai.list_isolation.nonstream.works", + exercised_on=["files"], + ) + def test_list_page_cursors_address_only_the_callers_own_files( + self, client: BatchClient, resources: ResourceManager + ) -> None: + """A list page's pagination cursors must address rows in that page. + + The proxy fronts one shared provider account, so the upstream page is the + whole organization's. The gateway narrows `data` to the files the caller + owns, and `first_id` / `last_id` have to be narrowed with it: left as the + upstream org's, they hand any caller raw provider file ids belonging to + other tenants, which is the handle the file routes accept. + """ + key = resources.key(user_id=f"e2e-file-list-{unique_marker()}") + + listed = unwrap(client.list_files(key=key)) + + expected_first = listed.data[0].id if listed.data else None + expected_last = listed.data[-1].id if listed.data else None + assert listed.first_id == expected_first, ( + f"first_id {listed.first_id!r} is not the first row this caller can see " + f"({expected_first!r}); the page leaked another caller's file id" + ) + assert listed.last_id == expected_last, ( + f"last_id {listed.last_id!r} is not the last row this caller can see " + f"({expected_last!r}); the page leaked another caller's file id" + ) + assert listed.has_more is not True, ( + "the page advertises another page, but the proxy never forwards a cursor " + "upstream, so following it re-serves this same page forever" + ) + @pytest.mark.covers( "llm.files.openai.retrieve.nonstream.works", exercised_on=["files"], diff --git a/tests/e2e/coverage_registry/llm_conversational.yaml b/tests/e2e/coverage_registry/llm_conversational.yaml index 82bee39b9b2..1ddc146c12d 100644 --- a/tests/e2e/coverage_registry/llm_conversational.yaml +++ b/tests/e2e/coverage_registry/llm_conversational.yaml @@ -63,6 +63,7 @@ - {id: llm.responses.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: responses, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.9 / LIT-4778", rationale: "Responses missing/empty input and missing model are rejected"} - {id: llm.responses.openai.basic.stream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Streaming via /v1/responses"} - {id: llm.responses.openai.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: nonstream, assertions: [works, cost_logged], source: "response_api_endpoints/endpoints.py:26", rationale: "Cost logged on responses"} +- {id: llm.responses.openai.passthrough.stream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [cost_logged], source: "test_passthrough_e2e.py", rationale: "A streamed POST /openai_passthrough/v1/responses is costed and keyed by the provider response id; it used to log a zero-cost row under a random id (LIT-5870)"} - {id: llm.responses.openai.tool_use.nonstream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: tool_use, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Tool calls via Responses API"} - {id: llm.responses.openai.vision.nonstream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: vision, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Vision via Responses API"} - {id: llm.responses.anthropic.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: responses, route: anthropic, capability: basic, streaming: nonstream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Responses w/ Anthropic translation (smoke)"} diff --git a/tests/e2e/coverage_registry/llm_nonconversational.yaml b/tests/e2e/coverage_registry/llm_nonconversational.yaml index bb7169509eb..674d369b49f 100644 --- a/tests/e2e/coverage_registry/llm_nonconversational.yaml +++ b/tests/e2e/coverage_registry/llm_nonconversational.yaml @@ -3,6 +3,7 @@ - {id: llm.embeddings.openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_embeddings_endpoint_e2e.py:23", rationale: "Core endpoint, live vector response"} - {id: llm.embeddings.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.3 / LIT-4778", rationale: "Missing model/input on /embeddings return client errors"} - {id: llm.embeddings.openai.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "SPEND_TRACKING_COVERAGE_MATRIX.md:34", rationale: "Cost tracking on embeddings"} +- {id: llm.embeddings.openai.passthrough.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "test_passthrough_e2e.py", rationale: "POST /openai_passthrough/v1/embeddings is costed; the route wrote no spend row at all, so budgets never saw traffic OpenAI was billing for (LIT-5870)"} - {id: llm.embeddings.azure_openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: embeddings, route: azure_openai, capability: basic, streaming: nonstream, assertions: [works], source: "llms/azure/azure.py", rationale: "Azure embeddings via translation"} - {id: llm.embeddings.bedrock.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "llms/bedrock/embed/embedding.py", rationale: "Bedrock Titan embeddings"} - {id: llm.embeddings.vertex.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "vertex_embeddings/embedding_handler.py", rationale: "Vertex embeddings"} @@ -13,6 +14,7 @@ - {id: llm.batches.openai.cancel.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Batch cancel"} - {id: llm.batches.openai.list.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Batch list envelope"} - {id: llm.batches.openai.file_lifecycle.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "File upload/retrieve/delete for batch flow"} +- {id: llm.batches.openai.passthrough.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_passthrough_e2e.py", rationale: "GET /openai_passthrough/v1/batches relays OpenAI's own batch page; the dedicated prefix must not bind as a provider name on the /{provider}/v1/batches route (LIT-5870)"} - {id: llm.batches.openai_encoded.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py", rationale: "Encoded scenario lifecycle"} - {id: llm.batches.openai_unified.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py", rationale: "Unified/managed-id scenario"} - {id: llm.batches.openai_model_param.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py", rationale: "Model-param scenario"} @@ -29,6 +31,8 @@ - {id: llm.files.openai.retrieve.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "files_endpoints.py", rationale: "File retrieve by id"} - {id: llm.files.openai.delete.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "files_endpoints.py", rationale: "File delete returns deleted=true"} - {id: llm.files.openai.list.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "files_endpoints.py", rationale: "File list paginated"} +- {id: llm.files.openai.list_isolation.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "GET /v1/files pagination cursors address only rows the caller owns; on a shared provider account the upstream cursors otherwise hand out other tenants' raw provider file ids (LIT-5870)"} +- {id: llm.files.openai.passthrough.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_passthrough_e2e.py", rationale: "POST/DELETE /openai_passthrough/v1/files relay OpenAI's own file object; the dedicated prefix must not bind as a provider name on the /{provider}/v1/files route (LIT-5870)"} - {id: llm.files.azure_openai.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: azure_openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:45", rationale: "Azure file upload managed backend"} - {id: llm.files.vertex.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:52", rationale: "Vertex file upload to GCS"} - {id: llm.files.bedrock.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:59", rationale: "Bedrock file upload to S3"} diff --git a/tests/e2e/lifecycle.py b/tests/e2e/lifecycle.py index 4ef25509905..c9a67ebdb8c 100644 --- a/tests/e2e/lifecycle.py +++ b/tests/e2e/lifecycle.py @@ -52,7 +52,7 @@ class ResourceManager: """ client: ResourceClient - _cleanups: List[Callable[[], None]] = field( + _cleanups: List[Callable[[], object]] = field( default_factory=list ) # mutable-ok: append-only teardown registry @@ -60,8 +60,11 @@ class ResourceManager: """No global setup needed today; present for lifecycle symmetry.""" return None - def defer(self, cleanup: Callable[[], None]) -> None: - """Register a teardown action for any resource the test just created.""" + def defer(self, cleanup: Callable[[], object]) -> None: + """Register a teardown action for any resource the test just created. + + Whatever the action returns is discarded, so a delete that answers with a + response model can be deferred directly.""" self._cleanups.append(cleanup) def key(self, models: list[str] | None = None, user_id: str | None = "e2e-test-user") -> str: diff --git a/tests/e2e/llm_translation/passthrough_client.py b/tests/e2e/llm_translation/passthrough_client.py index 439594f3624..e0dfae679a9 100644 --- a/tests/e2e/llm_translation/passthrough_client.py +++ b/tests/e2e/llm_translation/passthrough_client.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from pydantic import BaseModel, Field from proxy_client import ProxyClient -from e2e_http import Headers, StreamingResponse +from e2e_http import FileUploadForm, Headers, NoBody, Result, StreamingResponse from models import ChatMessage @@ -113,6 +113,76 @@ class OpenAIChatBody(BaseModel): max_completion_tokens: int = 64 +class PassthroughFileObject(BaseModel): + id: str + object: str | None = None + purpose: str | None = None + filename: str | None = None + bytes: int | None = None + + +class PassthroughFileDeleted(BaseModel): + id: str + deleted: bool + + +class PassthroughListEntry(BaseModel): + id: str + + +class ResponsesUsage(BaseModel): + input_tokens: int + output_tokens: int + + +class ResponsesObject(BaseModel): + id: str + usage: ResponsesUsage | None = None + + +class ResponsesStreamEvent(BaseModel): + """One SSE frame of a native Responses stream. Only the terminal frames carry a + `response`, so it stays optional and the deltas validate as themselves.""" + + type: str + response: ResponsesObject | None = None + + +def completed_responses_object(result: StreamingResponse) -> ResponsesObject | None: + """The `response.completed` frame's response object, or None if the stream never + completed. Its `id` is what the spend row is keyed by on this route, and its + usage is what the row is priced from.""" + events = ( + ResponsesStreamEvent.model_validate_json(payload) + for payload in result.stream_events + ) + completed = tuple( + event.response + for event in events + if event.type == "response.completed" and event.response is not None + ) + return completed[-1] if completed else None + + +class OpenAIResponsesBody(BaseModel): + model: str + input: str + stream: bool = False + + +class OpenAIEmbeddingBody(BaseModel): + model: str + input: str + + +class PassthroughBatchList(BaseModel): + """OpenAI's own batch page, relayed verbatim. `object` is required so a body + that is not an OpenAI list fails validation instead of passing vacuously.""" + + object: str + data: list[PassthroughListEntry] + + def _tags_header(tags: list[str] | None) -> str | None: return ",".join(tags) if tags else None @@ -196,6 +266,66 @@ class PassthroughClient: stream=stream, ) + # ---- OpenAI file/batch routes under /openai_passthrough ------------- + # + # Relayed to OpenAI untouched, which is the whole point of the prefix: the + # customer opts out of the gateway's managed-file handling here. + + def openai_passthrough_upload_file( + self, key: str, *, content: bytes, filename: str + ) -> Result[PassthroughFileObject]: + return self.proxy.transport.upload( + "/openai_passthrough/v1/files", + headers=self.proxy.transport.bearer(key), + form=FileUploadForm(purpose="batch"), + filename=filename, + content=content, + response_type=PassthroughFileObject, + ) + + def openai_passthrough_delete_file( + self, key: str, file_id: str + ) -> Result[PassthroughFileDeleted]: + return self.proxy.transport.delete( + f"/openai_passthrough/v1/files/{file_id}", + headers=self.proxy.transport.bearer(key), + json=NoBody(), + response_type=PassthroughFileDeleted, + ) + + def openai_passthrough_list_batches(self, key: str) -> Result[PassthroughBatchList]: + return self.proxy.transport.get( + "/openai_passthrough/v1/batches", + headers=self.proxy.transport.bearer(key), + params=NoBody(), + response_type=PassthroughBatchList, + ) + + # ---- OpenAI inference routes under /openai_passthrough ------------- + # + # Relayed to OpenAI verbatim, but still costed by the gateway: the customer + # budgets against this traffic, so a 200 that logs no spend is money the + # gateway never sees. + + def openai_passthrough_responses( + self, key: str, model: str, text: str, *, stream: bool = False + ) -> StreamingResponse: + return self.proxy.transport.send( + "/openai_passthrough/v1/responses", + headers=self.proxy.transport.bearer(key), + json=OpenAIResponsesBody(model=model, input=text, stream=stream), + stream=stream, + ) + + def openai_passthrough_embed( + self, key: str, model: str, text: str + ) -> StreamingResponse: + return self.proxy.transport.send( + "/openai_passthrough/v1/embeddings", + headers=self.proxy.transport.bearer(key), + json=OpenAIEmbeddingBody(model=model, input=text), + ) + def openai_chat( self, key: str, model: str, text: str, *, max_completion_tokens: int = 64 ) -> StreamingResponse: diff --git a/tests/e2e/llm_translation/test_passthrough_e2e.py b/tests/e2e/llm_translation/test_passthrough_e2e.py index b57164df9bb..b084c711a88 100644 --- a/tests/e2e/llm_translation/test_passthrough_e2e.py +++ b/tests/e2e/llm_translation/test_passthrough_e2e.py @@ -13,8 +13,8 @@ A passthrough call returning non-2xx fails hard (never a skip); once it returns import pytest -from e2e_config import unique_marker -from e2e_http import StreamingResponse, require_successful_call +from e2e_config import CHEAP_OPENAI_MODEL, unique_marker +from e2e_http import StreamingResponse, require_successful_call, unwrap from lifecycle import ResourceManager from models import KeyGenerateBody, SpendLogRow from passthrough_client import ( @@ -24,8 +24,11 @@ from passthrough_client import ( JsonSchema, JsonSchemaProperty, PassthroughClient, + completed_responses_object, ) +EMBEDDING_MODEL = "text-embedding-3-small" + pytestmark = pytest.mark.e2e @@ -210,3 +213,120 @@ class TestPassthroughModelAllowlist: "a key restricted to gemini-2.5-flash must be denied a claude passthrough call, " f"got {result.status_code}: {result.body[:300]}" ) + + +class TestOpenAIPassthroughPrefix: + """The dedicated `/openai_passthrough` prefix must reach OpenAI, not be + swallowed by the provider-scoped `/{provider}/v1/...` routes. + + The customer fronts OpenAI's own file and batch APIs through this prefix + precisely to opt out of the gateway's managed-file handling. `/v1/files` and + `/v1/batches` also answer `/{provider}/v1/files` and `/{provider}/v1/batches`, + so `openai_passthrough` used to bind as a provider name and the request died + inside the gateway with a provider-lookup error, never reaching OpenAI. + """ + + @pytest.mark.covers("llm.files.openai.passthrough.nonstream.works") + def test_passthrough_prefix_uploads_a_file_to_openai( + self, client: PassthroughClient, resources: ResourceManager, scoped_key: str + ) -> None: + content = f'{{"marker":"{unique_marker()}"}}\n'.encode() + uploaded = unwrap( + client.openai_passthrough_upload_file( + scoped_key, content=content, filename="e2e-passthrough-batch.jsonl" + ) + ) + resources.defer( + lambda: client.openai_passthrough_delete_file(scoped_key, uploaded.id) + ) + + assert uploaded.object == "file", ( + f"/openai_passthrough/v1/files did not relay OpenAI's file object: {uploaded}" + ) + assert uploaded.purpose == "batch" + assert uploaded.bytes == len(content) + + @pytest.mark.covers("llm.batches.openai.passthrough.nonstream.works") + def test_passthrough_prefix_lists_batches_from_openai( + self, client: PassthroughClient, scoped_key: str + ) -> None: + listed = unwrap(client.openai_passthrough_list_batches(scoped_key)) + + assert listed.object == "list", ( + f"/openai_passthrough/v1/batches did not relay OpenAI's batch page: {listed}" + ) + + +class TestOpenAIPassthroughSpend: + """A call relayed to OpenAI's own endpoints must still be costed. + + The customer routes native OpenAI traffic through `/openai_passthrough` and + budgets against it, so a call that returns 200 while logging no spend is money + the gateway never sees and a budget that never trips. Streamed Responses calls + and embeddings each used to land exactly that way, on separate code paths. + """ + + @pytest.mark.covers("llm.responses.openai.passthrough.stream.cost_logged") + def test_streamed_responses_call_logs_its_cost( + self, client: PassthroughClient, scoped_key: str + ) -> None: + result = client.openai_passthrough_responses( + scoped_key, + CHEAP_OPENAI_MODEL, + f"Say hi in one word. {unique_marker()}", + stream=True, + ) + require_successful_call(result) + assert result.chunks > 0, "streamed responses passthrough produced no events" + + completed = completed_responses_object(result) + assert completed is not None, ( + f"the stream never delivered a response.completed frame, so there is no " + f"provider id to reconcile against: last events {result.stream_events[-3:]}" + ) + assert completed.usage is not None, ( + f"the completed response carried no usage to price from: {completed}" + ) + + rows = client.proxy.poll_logs_for_request_id( + completed.id, predicate=lambda rows: (rows[0].spend or 0) > 0 + ) + assert rows, ( + f"no spend row for the response the customer was served ({completed.id}); " + "a streamed passthrough call OpenAI bills them for is invisible to the " + "gateway's own spend and budgets" + ) + row = rows[0] + assert (row.spend or 0) > 0, f"streamed responses passthrough was not costed: {row}" + assert row.prompt_tokens == completed.usage.input_tokens, ( + f"logged {row.prompt_tokens} prompt tokens, the response the customer read " + f"reported {completed.usage.input_tokens}" + ) + assert row.completion_tokens == completed.usage.output_tokens, ( + f"logged {row.completion_tokens} completion tokens, the response the customer " + f"read reported {completed.usage.output_tokens}" + ) + + @pytest.mark.covers("llm.embeddings.openai.passthrough.nonstream.cost_logged") + def test_embeddings_call_logs_its_cost( + self, client: PassthroughClient, scoped_key: str + ) -> None: + result = client.openai_passthrough_embed( + scoped_key, EMBEDDING_MODEL, f"cost this sentence {unique_marker()}" + ) + require_successful_call(result) + assert result.call_id, "embeddings passthrough returned no x-litellm-call-id" + + rows = client.proxy.poll_logs_for_request_id( + result.call_id, predicate=lambda rows: (rows[0].spend or 0) > 0 + ) + assert rows, ( + f"no spend row for embeddings call {result.call_id}; the customer is billed " + "by OpenAI for tokens the gateway never counted against their budget" + ) + row = rows[0] + assert (row.spend or 0) > 0, f"embeddings passthrough was not costed: {row}" + assert (row.prompt_tokens or 0) > 0, ( + f"the embeddings row logged no prompt tokens, so whatever cost it carries " + f"was not computed from the real usage: {row}" + ) From b7017a79497012fc5e1bfb533d414c600e121aa0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:13:39 +0000 Subject: [PATCH 16/70] fix(model_prices): consolidate nine open registry audits into one changeset Combines the model-cost-map data from #35911, #36017, #36080, #36113, #36188, #36444, #37029, #37252 and #37632 onto current litellm_internal_staging, merged per entry field so older branches no longer revert fields the base has gained since they were opened. Drops the Gemini deprecation dates from #36188 and the text-embedding-004 date from #36080 that the official docs contradict. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 438 ++++++++++++++++-- model_prices_and_context_window.json | 438 ++++++++++++++++-- .../llm_cost_calc/test_llm_cost_calc_utils.py | 41 ++ ...est_gemini_3_1_flash_lite_image_pricing.py | 150 ++++++ 4 files changed, 979 insertions(+), 88 deletions(-) create mode 100644 tests/test_litellm/test_gemini_3_1_flash_lite_image_pricing.py diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index db6627bad58..5ef50ed4dc5 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -12401,8 +12401,8 @@ "input_cost_per_token": 3e-06, "litellm_provider": "anthropic", "max_input_tokens": 1000000, - "max_output_tokens": 64000, - "max_tokens": 64000, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { @@ -12787,7 +12787,8 @@ "us": 1.1 }, "supports_output_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "supports_native_structured_output": true }, "claude-opus-5": { "deprecation_date": "2027-07-24", @@ -13350,7 +13351,8 @@ "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", - "output_cost_per_token": 2e-06 + "output_cost_per_token": 2e-06, + "deprecation_date": "2025-09-15" }, "command-a-03-2025": { "input_cost_per_token": 2.5e-06, @@ -13371,7 +13373,8 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, - "supports_tool_choice": true + "supports_tool_choice": true, + "deprecation_date": "2025-09-15" }, "command-nightly": { "input_cost_per_token": 1e-06, @@ -13391,7 +13394,8 @@ "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "deprecation_date": "2025-09-15" }, "command-r-08-2024": { "input_cost_per_token": 1.5e-07, @@ -13413,7 +13417,8 @@ "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "deprecation_date": "2025-09-15" }, "command-r-plus-08-2024": { "input_cost_per_token": 2.5e-06, @@ -18893,6 +18898,106 @@ }, "web_search_billing_unit": "per_query" }, + "gemini-3.1-flash-lite-image": { + "input_cost_per_image": 0.00028, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 65536, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "image_generation", + "output_cost_per_image": 0.0336, + "output_cost_per_image_token": 3e-05, + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": false, + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "gemini/gemini-3.1-flash-lite-image": { + "rpm": 1000, + "tpm": 4000000, + "input_cost_per_image": 0.00028, + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, + "litellm_provider": "gemini", + "max_input_tokens": 65536, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "image_generation", + "output_cost_per_image": 0.0336, + "output_cost_per_image_token": 3e-05, + "output_cost_per_token": 1.5e-06, + "output_cost_per_token_batches": 7.5e-07, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite-image", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "vertex_ai/gemini-3.1-flash-lite-image": { + "input_cost_per_image": 0.00028, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 65536, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "image_generation", + "output_cost_per_image": 0.0336, + "output_cost_per_image_token": 3e-05, + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": false, + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, "gemini-3.1-flash-image": { "deprecation_date": "2027-05-28", "input_cost_per_image": 0.00056, @@ -24142,7 +24247,8 @@ "supports_response_schema": false, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": false + "supports_vision": false, + "deprecation_date": "2027-01-20" }, "gpt-4o-mini": { "cache_read_input_token_cost": 7.5e-08, @@ -25567,6 +25673,154 @@ "supports_web_search": true, "supports_xhigh_reasoning_effort": true }, + "gpt-5.6-cyber": { + "cache_creation_input_token_cost": 1.5625e-05, + "cache_creation_input_token_cost_above_272k_tokens": 3.125e-05, + "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_token_cost_above_272k_tokens": 2.5e-06, + "input_cost_per_token": 1.25e-05, + "input_cost_per_token_above_272k_tokens": 2.5e-05, + "litellm_provider": "openai", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "output_cost_per_token_above_272k_tokens": 0.0001125, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "source": "https://platform.openai.com/docs/models/gpt-5.6-cyber", + "supports_computer_use": true, + "supports_parallel_function_calling": true + }, + "daybreak-red-latest": { + "cache_creation_input_token_cost": 1.5625e-05, + "cache_creation_input_token_cost_above_272k_tokens": 3.125e-05, + "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_token_cost_above_272k_tokens": 2.5e-06, + "input_cost_per_token": 1.25e-05, + "input_cost_per_token_above_272k_tokens": 2.5e-05, + "litellm_provider": "openai", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "output_cost_per_token_above_272k_tokens": 0.0001125, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "source": "https://platform.openai.com/docs/models/daybreak-red-latest", + "supports_computer_use": true, + "supports_parallel_function_calling": true + }, + "daybreak-blue-latest": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "source": "https://platform.openai.com/docs/models/daybreak-blue-latest", + "supports_parallel_function_calling": true + }, + "chat-latest": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "source": "https://platform.openai.com/docs/models/chat-latest", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "gpt-5.5": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, @@ -29330,28 +29584,30 @@ "mistral/codestral-2508": { "input_cost_per_token": 3e-07, "litellm_provider": "mistral", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9e-07, - "source": "https://mistral.ai/news/codestral-25-08", + "source": "https://docs.mistral.ai/models/model-cards/codestral-25-08", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/codestral-latest": { - "input_cost_per_token": 1e-06, + "input_cost_per_token": 3e-07, "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3e-06, + "output_cost_per_token": 9e-07, "supports_assistant_prefill": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "source": "https://docs.mistral.ai/models/model-cards/codestral-25-08", + "supports_function_calling": true }, "mistral/codestral-mamba-latest": { "input_cost_per_token": 2.5e-07, @@ -29550,6 +29806,16 @@ ], "source": "https://mistral.ai/pricing#api-pricing" }, + "mistral/mistral-ocr-4-1": { + "annotation_cost_per_page": 0.005, + "litellm_provider": "mistral", + "mode": "ocr", + "ocr_cost_per_page": 0.004, + "source": "https://docs.mistral.ai/models/model-cards/ocr-4-1", + "supported_endpoints": [ + "/v1/ocr" + ] + }, "mistral/mistral-ocr-2505-completion": { "deprecation_date": "2026-05-31", "litellm_provider": "mistral", @@ -29874,19 +30140,19 @@ "supports_tool_choice": true }, "mistral/mistral-small-latest": { - "input_cost_per_token": 6e-08, + "input_cost_per_token": 1.5e-07, "litellm_provider": "mistral", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, "mode": "chat", - "output_cost_per_token": 1.8e-07, - "source": "https://mistral.ai/pricing", + "output_cost_per_token": 6e-07, + "source": "https://docs.mistral.ai/models/model-cards/mistral-small-4-0-26-03", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_reasoning": true }, "mistral/mistral-small-3-2-2506": { "deprecation_date": "2026-07-31", @@ -32708,6 +32974,31 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "openrouter/anthropic/claude-opus-5": { + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://openrouter.ai/anthropic/claude-opus-5", + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_max_reasoning_effort": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "openrouter/bytedance/ui-tars-1.5-7b": { "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", @@ -32816,6 +33107,38 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/deepseek/deepseek-v4-pro": { + "input_cost_per_token": 1.32e-06, + "input_cost_per_token_cache_hit": 4.4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 3.96e-06, + "source": "https://openrouter.ai/deepseek/deepseek-v4-pro", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "openrouter/deepseek/deepseek-v4-pro-0813": { + "input_cost_per_token": 1.32e-06, + "input_cost_per_token_cache_hit": 4.4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 3.96e-06, + "source": "https://openrouter.ai/deepseek/deepseek-v4-pro-0813", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "openrouter/google/gemini-2.0-flash-001": { "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7e-07, @@ -35341,7 +35664,8 @@ "max_output_tokens": 4096, "max_tokens": 4096, "mode": "rerank", - "output_cost_per_token": 0.0 + "output_cost_per_token": 0.0, + "deprecation_date": "2025-04-30" }, "rerank-english-v3.0": { "input_cost_per_query": 0.002, @@ -35361,7 +35685,8 @@ "max_output_tokens": 4096, "max_tokens": 4096, "mode": "rerank", - "output_cost_per_token": 0.0 + "output_cost_per_token": 0.0, + "deprecation_date": "2025-04-30" }, "rerank-multilingual-v3.0": { "input_cost_per_query": 0.002, @@ -39813,13 +40138,13 @@ "supports_tool_choice": true }, "vertex_ai/deepseek-ai/deepseek-v3.1-maas": { - "input_cost_per_token": 1.35e-06, + "input_cost_per_token": 6e-07, "litellm_provider": "vertex_ai-deepseek_models", "max_input_tokens": 163840, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 5.4e-06, + "output_cost_per_token": 1.7e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supported_regions": [ "us-central1" @@ -40653,13 +40978,13 @@ "supports_vision": true }, "vertex_ai/openai/gpt-oss-120b-maas": { - "input_cost_per_token": 1.5e-07, + "input_cost_per_token": 9e-08, "litellm_provider": "vertex_ai-openai_models", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 6e-07, + "output_cost_per_token": 3.6e-07, "source": "https://console.cloud.google.com/vertex-ai/publishers/openai/model-garden/gpt-oss-120b-maas", "supports_reasoning": true }, @@ -40741,13 +41066,13 @@ "supports_web_search": true }, "vertex_ai/qwen/qwen3-235b-a22b-instruct-2507-maas": { - "input_cost_per_token": 2.5e-07, + "input_cost_per_token": 2.2e-07, "litellm_provider": "vertex_ai-qwen_models", "max_input_tokens": 262144, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 1e-06, + "output_cost_per_token": 8.8e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_regions": [ "global", @@ -40757,13 +41082,13 @@ "supports_tool_choice": true }, "vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas": { - "input_cost_per_token": 1e-06, + "input_cost_per_token": 2.2e-07, "litellm_provider": "vertex_ai-qwen_models", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 4e-06, + "output_cost_per_token": 1.8e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_regions": [ "global" @@ -41738,7 +42063,8 @@ "supports_prompt_caching": true, "supports_response_schema": false, "supports_tool_choice": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-3-mini": { "cache_read_input_token_cost": 7.5e-08, @@ -41856,7 +42182,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-fast-reasoning": { "cache_read_input_token_cost": 5e-08, @@ -41925,7 +42252,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-1-fast": { "cache_read_input_token_cost": 5e-08, @@ -42253,7 +42581,8 @@ "output_cost_per_token_above_200k_tokens": 4e-06, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "deprecation_date": "2026-05-15" }, "xai/grok-code-fast-1": { "cache_read_input_token_cost": 2e-07, @@ -42273,7 +42602,8 @@ "output_cost_per_token_above_200k_tokens": 4e-06, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "deprecation_date": "2026-05-15" }, "xai/grok-code-fast-1-0825": { "cache_read_input_token_cost": 2e-07, @@ -42293,7 +42623,8 @@ "output_cost_per_token_above_200k_tokens": 4e-06, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "deprecation_date": "2026-05-15" }, "xai/grok-vision-beta": { "input_cost_per_image": 5e-06, @@ -46643,7 +46974,8 @@ "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "deprecation_date": "2027-01-20" }, "gpt-realtime-whisper": { "input_cost_per_second": 0.0002833333333333333, @@ -48700,7 +49032,8 @@ "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true }, "claude-mythos-preview": { "cache_creation_input_token_cost": 1.25e-05, @@ -48733,7 +49066,8 @@ "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true }, "gemini/gemini-robotics-er-2-streaming-preview": { "input_cost_per_audio_token": 2e-06, @@ -48850,6 +49184,22 @@ ], "supports_audio_output": true }, + "mistral/zai-glm-5-2": { + "cache_read_input_token_cost": 1.4e-07, + "input_cost_per_token": 1.4e-06, + "litellm_provider": "mistral", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "source": "https://docs.mistral.ai/models/model-cards/zai-glm-5-2", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "fallback_generalizations": { "rules": [ { diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index db6627bad58..5ef50ed4dc5 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -12401,8 +12401,8 @@ "input_cost_per_token": 3e-06, "litellm_provider": "anthropic", "max_input_tokens": 1000000, - "max_output_tokens": 64000, - "max_tokens": 64000, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { @@ -12787,7 +12787,8 @@ "us": 1.1 }, "supports_output_config": true, - "prompt_cache_min_tokens": 512 + "prompt_cache_min_tokens": 512, + "supports_native_structured_output": true }, "claude-opus-5": { "deprecation_date": "2027-07-24", @@ -13350,7 +13351,8 @@ "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", - "output_cost_per_token": 2e-06 + "output_cost_per_token": 2e-06, + "deprecation_date": "2025-09-15" }, "command-a-03-2025": { "input_cost_per_token": 2.5e-06, @@ -13371,7 +13373,8 @@ "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, - "supports_tool_choice": true + "supports_tool_choice": true, + "deprecation_date": "2025-09-15" }, "command-nightly": { "input_cost_per_token": 1e-06, @@ -13391,7 +13394,8 @@ "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "deprecation_date": "2025-09-15" }, "command-r-08-2024": { "input_cost_per_token": 1.5e-07, @@ -13413,7 +13417,8 @@ "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "deprecation_date": "2025-09-15" }, "command-r-plus-08-2024": { "input_cost_per_token": 2.5e-06, @@ -18893,6 +18898,106 @@ }, "web_search_billing_unit": "per_query" }, + "gemini-3.1-flash-lite-image": { + "input_cost_per_image": 0.00028, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 65536, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "image_generation", + "output_cost_per_image": 0.0336, + "output_cost_per_image_token": 3e-05, + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": false, + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "gemini/gemini-3.1-flash-lite-image": { + "rpm": 1000, + "tpm": 4000000, + "input_cost_per_image": 0.00028, + "input_cost_per_token": 2.5e-07, + "input_cost_per_token_batches": 1.25e-07, + "litellm_provider": "gemini", + "max_input_tokens": 65536, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "image_generation", + "output_cost_per_image": 0.0336, + "output_cost_per_image_token": 3e-05, + "output_cost_per_token": 1.5e-06, + "output_cost_per_token_batches": 7.5e-07, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-lite-image", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, + "vertex_ai/gemini-3.1-flash-lite-image": { + "input_cost_per_image": 0.00028, + "input_cost_per_token": 2.5e-07, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 65536, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "image_generation", + "output_cost_per_image": 0.0336, + "output_cost_per_image_token": 3e-05, + "output_cost_per_token": 1.5e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text", + "image" + ], + "supports_function_calling": false, + "supports_prompt_caching": true, + "supports_response_schema": false, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_vision": true + }, "gemini-3.1-flash-image": { "deprecation_date": "2027-05-28", "input_cost_per_image": 0.00056, @@ -24142,7 +24247,8 @@ "supports_response_schema": false, "supports_system_messages": true, "supports_tool_choice": true, - "supports_vision": false + "supports_vision": false, + "deprecation_date": "2027-01-20" }, "gpt-4o-mini": { "cache_read_input_token_cost": 7.5e-08, @@ -25567,6 +25673,154 @@ "supports_web_search": true, "supports_xhigh_reasoning_effort": true }, + "gpt-5.6-cyber": { + "cache_creation_input_token_cost": 1.5625e-05, + "cache_creation_input_token_cost_above_272k_tokens": 3.125e-05, + "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_token_cost_above_272k_tokens": 2.5e-06, + "input_cost_per_token": 1.25e-05, + "input_cost_per_token_above_272k_tokens": 2.5e-05, + "litellm_provider": "openai", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "output_cost_per_token_above_272k_tokens": 0.0001125, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "source": "https://platform.openai.com/docs/models/gpt-5.6-cyber", + "supports_computer_use": true, + "supports_parallel_function_calling": true + }, + "daybreak-red-latest": { + "cache_creation_input_token_cost": 1.5625e-05, + "cache_creation_input_token_cost_above_272k_tokens": 3.125e-05, + "cache_read_input_token_cost": 1.25e-06, + "cache_read_input_token_cost_above_272k_tokens": 2.5e-06, + "input_cost_per_token": 1.25e-05, + "input_cost_per_token_above_272k_tokens": 2.5e-05, + "litellm_provider": "openai", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.5e-05, + "output_cost_per_token_above_272k_tokens": 0.0001125, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "source": "https://platform.openai.com/docs/models/daybreak-red-latest", + "supports_computer_use": true, + "supports_parallel_function_calling": true + }, + "daybreak-blue-latest": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_272k_tokens": 1e-06, + "input_cost_per_token": 5e-06, + "input_cost_per_token_above_272k_tokens": 1e-05, + "litellm_provider": "openai", + "max_input_tokens": 1050000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "output_cost_per_token_above_272k_tokens": 4.5e-05, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "source": "https://platform.openai.com/docs/models/daybreak-blue-latest", + "supports_parallel_function_calling": true + }, + "chat-latest": { + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openai", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "source": "https://platform.openai.com/docs/models/chat-latest", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "gpt-5.5": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, @@ -29330,28 +29584,30 @@ "mistral/codestral-2508": { "input_cost_per_token": 3e-07, "litellm_provider": "mistral", - "max_input_tokens": 256000, - "max_output_tokens": 256000, - "max_tokens": 256000, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9e-07, - "source": "https://mistral.ai/news/codestral-25-08", + "source": "https://docs.mistral.ai/models/model-cards/codestral-25-08", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/codestral-latest": { - "input_cost_per_token": 1e-06, + "input_cost_per_token": 3e-07, "litellm_provider": "mistral", - "max_input_tokens": 32000, - "max_output_tokens": 8191, - "max_tokens": 8191, + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, "mode": "chat", - "output_cost_per_token": 3e-06, + "output_cost_per_token": 9e-07, "supports_assistant_prefill": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "source": "https://docs.mistral.ai/models/model-cards/codestral-25-08", + "supports_function_calling": true }, "mistral/codestral-mamba-latest": { "input_cost_per_token": 2.5e-07, @@ -29550,6 +29806,16 @@ ], "source": "https://mistral.ai/pricing#api-pricing" }, + "mistral/mistral-ocr-4-1": { + "annotation_cost_per_page": 0.005, + "litellm_provider": "mistral", + "mode": "ocr", + "ocr_cost_per_page": 0.004, + "source": "https://docs.mistral.ai/models/model-cards/ocr-4-1", + "supported_endpoints": [ + "/v1/ocr" + ] + }, "mistral/mistral-ocr-2505-completion": { "deprecation_date": "2026-05-31", "litellm_provider": "mistral", @@ -29874,19 +30140,19 @@ "supports_tool_choice": true }, "mistral/mistral-small-latest": { - "input_cost_per_token": 6e-08, + "input_cost_per_token": 1.5e-07, "litellm_provider": "mistral", - "max_input_tokens": 131072, - "max_output_tokens": 131072, - "max_tokens": 131072, + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, "mode": "chat", - "output_cost_per_token": 1.8e-07, - "source": "https://mistral.ai/pricing", + "output_cost_per_token": 6e-07, + "source": "https://docs.mistral.ai/models/model-cards/mistral-small-4-0-26-03", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_reasoning": true }, "mistral/mistral-small-3-2-2506": { "deprecation_date": "2026-07-31", @@ -32708,6 +32974,31 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "openrouter/anthropic/claude-opus-5": { + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "cache_creation_input_token_cost": 6.25e-06, + "cache_read_input_token_cost": 5e-07, + "input_cost_per_token": 5e-06, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-05, + "source": "https://openrouter.ai/anthropic/claude-opus-5", + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_max_reasoning_effort": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "openrouter/bytedance/ui-tars-1.5-7b": { "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", @@ -32816,6 +33107,38 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/deepseek/deepseek-v4-pro": { + "input_cost_per_token": 1.32e-06, + "input_cost_per_token_cache_hit": 4.4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 3.96e-06, + "source": "https://openrouter.ai/deepseek/deepseek-v4-pro", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "openrouter/deepseek/deepseek-v4-pro-0813": { + "input_cost_per_token": 1.32e-06, + "input_cost_per_token_cache_hit": 4.4e-08, + "litellm_provider": "openrouter", + "max_input_tokens": 1048576, + "max_output_tokens": 384000, + "max_tokens": 384000, + "mode": "chat", + "output_cost_per_token": 3.96e-06, + "source": "https://openrouter.ai/deepseek/deepseek-v4-pro-0813", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "openrouter/google/gemini-2.0-flash-001": { "deprecation_date": "2026-06-01", "input_cost_per_audio_token": 7e-07, @@ -35341,7 +35664,8 @@ "max_output_tokens": 4096, "max_tokens": 4096, "mode": "rerank", - "output_cost_per_token": 0.0 + "output_cost_per_token": 0.0, + "deprecation_date": "2025-04-30" }, "rerank-english-v3.0": { "input_cost_per_query": 0.002, @@ -35361,7 +35685,8 @@ "max_output_tokens": 4096, "max_tokens": 4096, "mode": "rerank", - "output_cost_per_token": 0.0 + "output_cost_per_token": 0.0, + "deprecation_date": "2025-04-30" }, "rerank-multilingual-v3.0": { "input_cost_per_query": 0.002, @@ -39813,13 +40138,13 @@ "supports_tool_choice": true }, "vertex_ai/deepseek-ai/deepseek-v3.1-maas": { - "input_cost_per_token": 1.35e-06, + "input_cost_per_token": 6e-07, "litellm_provider": "vertex_ai-deepseek_models", "max_input_tokens": 163840, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 5.4e-06, + "output_cost_per_token": 1.7e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supported_regions": [ "us-central1" @@ -40653,13 +40978,13 @@ "supports_vision": true }, "vertex_ai/openai/gpt-oss-120b-maas": { - "input_cost_per_token": 1.5e-07, + "input_cost_per_token": 9e-08, "litellm_provider": "vertex_ai-openai_models", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 6e-07, + "output_cost_per_token": 3.6e-07, "source": "https://console.cloud.google.com/vertex-ai/publishers/openai/model-garden/gpt-oss-120b-maas", "supports_reasoning": true }, @@ -40741,13 +41066,13 @@ "supports_web_search": true }, "vertex_ai/qwen/qwen3-235b-a22b-instruct-2507-maas": { - "input_cost_per_token": 2.5e-07, + "input_cost_per_token": 2.2e-07, "litellm_provider": "vertex_ai-qwen_models", "max_input_tokens": 262144, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", - "output_cost_per_token": 1e-06, + "output_cost_per_token": 8.8e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_regions": [ "global", @@ -40757,13 +41082,13 @@ "supports_tool_choice": true }, "vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas": { - "input_cost_per_token": 1e-06, + "input_cost_per_token": 2.2e-07, "litellm_provider": "vertex_ai-qwen_models", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", - "output_cost_per_token": 4e-06, + "output_cost_per_token": 1.8e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_regions": [ "global" @@ -41738,7 +42063,8 @@ "supports_prompt_caching": true, "supports_response_schema": false, "supports_tool_choice": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-3-mini": { "cache_read_input_token_cost": 7.5e-08, @@ -41856,7 +42182,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-fast-reasoning": { "cache_read_input_token_cost": 5e-08, @@ -41925,7 +42252,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true, - "supports_web_search": true + "supports_web_search": true, + "deprecation_date": "2026-05-15" }, "xai/grok-4-1-fast": { "cache_read_input_token_cost": 5e-08, @@ -42253,7 +42581,8 @@ "output_cost_per_token_above_200k_tokens": 4e-06, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "deprecation_date": "2026-05-15" }, "xai/grok-code-fast-1": { "cache_read_input_token_cost": 2e-07, @@ -42273,7 +42602,8 @@ "output_cost_per_token_above_200k_tokens": 4e-06, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "deprecation_date": "2026-05-15" }, "xai/grok-code-fast-1-0825": { "cache_read_input_token_cost": 2e-07, @@ -42293,7 +42623,8 @@ "output_cost_per_token_above_200k_tokens": 4e-06, "cache_read_input_token_cost_above_200k_tokens": 4e-07, "supports_response_schema": true, - "supports_vision": true + "supports_vision": true, + "deprecation_date": "2026-05-15" }, "xai/grok-vision-beta": { "input_cost_per_image": 5e-06, @@ -46643,7 +46974,8 @@ "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "deprecation_date": "2027-01-20" }, "gpt-realtime-whisper": { "input_cost_per_second": 0.0002833333333333333, @@ -48700,7 +49032,8 @@ "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true }, "claude-mythos-preview": { "cache_creation_input_token_cost": 1.25e-05, @@ -48733,7 +49066,8 @@ "supports_sampling_params": false, "supports_tool_choice": true, "supports_vision": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": true }, "gemini/gemini-robotics-er-2-streaming-preview": { "input_cost_per_audio_token": 2e-06, @@ -48850,6 +49184,22 @@ ], "supports_audio_output": true }, + "mistral/zai-glm-5-2": { + "cache_read_input_token_cost": 1.4e-07, + "input_cost_per_token": 1.4e-06, + "litellm_provider": "mistral", + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 4.4e-06, + "source": "https://docs.mistral.ai/models/model-cards/zai-glm-5-2", + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "fallback_generalizations": { "rules": [ { diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 06be96fefdf..1cecd3e5f7c 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -1056,6 +1056,47 @@ def test_generic_cost_per_token_gpt56_terra_cache_costs_by_tier_and_context( assert prompt_cost == pytest.approx(expected_prompt_cost) +@pytest.mark.parametrize("model", ["gpt-5.6-cyber", "daybreak-red-latest"]) +@pytest.mark.parametrize( + "prompt_tokens,input_rate,cache_write_rate,cache_read_rate,output_rate", + [ + (100000, 1.25e-5, 1.5625e-5, 1.25e-6, 7.5e-5), + (300000, 2.5e-5, 3.125e-5, 2.5e-6, 1.125e-4), + ], +) +def test_generic_cost_per_token_gpt56_cyber( + model, prompt_tokens, input_rate, cache_write_rate, cache_read_rate, output_rate +): + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + cached_tokens = 50000 + cache_write_tokens = 40000 + text_tokens = prompt_tokens - cached_tokens - cache_write_tokens + completion_tokens = 1000 + usage = Usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=prompt_tokens + completion_tokens, + prompt_tokens_details=PromptTokensDetailsWrapper( + cached_tokens=cached_tokens, cache_write_tokens=cache_write_tokens + ), + ) + + prompt_cost, completion_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider="openai", + ) + + assert prompt_cost == pytest.approx( + text_tokens * input_rate + + cached_tokens * cache_read_rate + + cache_write_tokens * cache_write_rate + ) + assert completion_cost == pytest.approx(completion_tokens * output_rate) + + @pytest.mark.parametrize( "model,input_cost,output_cost,cache_read_cost", [ diff --git a/tests/test_litellm/test_gemini_3_1_flash_lite_image_pricing.py b/tests/test_litellm/test_gemini_3_1_flash_lite_image_pricing.py new file mode 100644 index 00000000000..adc306971e3 --- /dev/null +++ b/tests/test_litellm/test_gemini_3_1_flash_lite_image_pricing.py @@ -0,0 +1,150 @@ +"""Pricing entry for ``gemini-3.1-flash-lite-image`` (Google's Nano Banana 2 Lite). + +Google publishes: $0.25/1M input, $1.50/1M text output, and $30/1M image-output +tokens for the Lite image model (https://cloud.google.com/vertex-ai/generative-ai/pricing). +A 1K image is ~1120 output image tokens => ~$0.0336 / image. + +Without this entry, ``completion_cost`` raises "model isn't mapped yet" and Vertex +generateContent pass-through cost tracking silently logs $0. These tests pin the +values in both the primary price map and the ``litellm/`` backup, and verify +``get_model_info`` / ``completion_cost`` surface them. +""" + +import json +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + +import litellm +from litellm import completion_cost +from litellm.types.utils import CompletionTokensDetailsWrapper, ModelResponse, Usage + +VARIANTS = [ + "gemini-3.1-flash-lite-image", + "gemini/gemini-3.1-flash-lite-image", + "vertex_ai/gemini-3.1-flash-lite-image", +] + +EXPECTED = { + "input_cost_per_token": 2.5e-07, + "output_cost_per_token": 1.5e-06, + "output_cost_per_image_token": 3e-05, + "mode": "image_generation", +} + +EXPECTED_CAPABILITIES = { + "max_output_tokens": 4096, + "max_tokens": 4096, + "supports_response_schema": False, + "supports_reasoning": True, +} + +EXPECTED_PER_ROUTE = { + "gemini-3.1-flash-lite-image": { + "supports_prompt_caching": True, + "supports_function_calling": False, + }, + "vertex_ai/gemini-3.1-flash-lite-image": { + "supports_prompt_caching": True, + "supports_function_calling": False, + }, + "gemini/gemini-3.1-flash-lite-image": { + "supports_prompt_caching": False, + "supports_function_calling": True, + "input_cost_per_token_batches": 1.25e-07, + "output_cost_per_token_batches": 7.5e-07, + }, +} + + +def _load_json(path: str) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _backup_path() -> str: + return os.path.join( + os.path.dirname(litellm.__file__), + "model_prices_and_context_window_backup.json", + ) + + +def _main_path() -> str: + return os.path.join( + os.path.dirname(__file__), "..", "..", "model_prices_and_context_window.json" + ) + + +class TestGeminiFlashLiteImagePricingData: + """Both price maps must carry Google's published Nano Banana 2 Lite costs.""" + + def test_present_in_both_maps(self): + main = _load_json(_main_path()) + backup = _load_json(_backup_path()) + for key in VARIANTS: + for label, data in (("main", main), ("backup", backup)): + assert key in data, f"{key} missing from {label} JSON" + entry = data[key] + for field, value in EXPECTED.items(): + assert entry[field] == value, f"{key} {field} in {label}: {entry.get(field)} != {value}" + + def test_capabilities_match_model_cards(self): + main = _load_json(_main_path()) + backup = _load_json(_backup_path()) + for key in VARIANTS: + expected = {**EXPECTED_CAPABILITIES, **EXPECTED_PER_ROUTE[key]} + for label, data in (("main", main), ("backup", backup)): + entry = data[key] + for field, value in expected.items(): + assert entry[field] == value, f"{key} {field} in {label}: {entry.get(field)} != {value}" + + def test_grounding_fields_absent(self): + """Grounding with Google Search is unsupported on Lite, so no search pricing.""" + for path in (_main_path(), _backup_path()): + data = _load_json(path) + for key in VARIANTS: + for field in ( + "supports_web_search", + "search_context_cost_per_query", + "web_search_billing_unit", + ): + assert field not in data[key], f"{key} should not define {field}" + + def test_image_output_pricing_consistent(self): + """1120 image-output tokens * output_cost_per_image_token == output_cost_per_image.""" + backup = _load_json(_backup_path()) + entry = backup["gemini-3.1-flash-lite-image"] + assert round(1120 * entry["output_cost_per_image_token"], 6) == entry["output_cost_per_image"] + + +class TestGeminiFlashLiteImageModelInfo: + """``get_model_info`` and ``completion_cost`` must report the new costs.""" + + def test_get_model_info_and_cost(self): + original = litellm.model_cost + try: + litellm.model_cost = _load_json(_backup_path()) + info = litellm.get_model_info("gemini-3.1-flash-lite-image") + assert info["input_cost_per_token"] == EXPECTED["input_cost_per_token"] + assert info["output_cost_per_token"] == EXPECTED["output_cost_per_token"] + + resp = ModelResponse() + resp.model = "gemini-3.1-flash-lite-image" + resp.usage = Usage( + prompt_tokens=7, + completion_tokens=1120, + total_tokens=1127, + completion_tokens_details=CompletionTokensDetailsWrapper( + image_tokens=1120, text_tokens=0 + ), + ) + cost = completion_cost( + completion_response=resp, + model="gemini-3.1-flash-lite-image", + custom_llm_provider="vertex_ai", + ) + expected_cost = 1120 * 3e-05 + 7 * 2.5e-07 + assert abs(cost - expected_cost) < 1e-6, f"unexpected cost {cost}" + finally: + litellm.model_cost = original From e1ff8b27fad4859201fa1a2d02bb5292e5bf01fa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:23:26 +0000 Subject: [PATCH 17/70] test: satisfy test-quality gate in consolidated registry tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llm_cost_calc/test_llm_cost_calc_utils.py | 12 +++++++++--- .../test_gemini_3_1_flash_lite_image_pricing.py | 3 --- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 1cecd3e5f7c..137dc1d8f66 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -1065,10 +1065,16 @@ def test_generic_cost_per_token_gpt56_terra_cache_costs_by_tier_and_context( ], ) def test_generic_cost_per_token_gpt56_cyber( - model, prompt_tokens, input_rate, cache_write_rate, cache_read_rate, output_rate + model, + prompt_tokens, + input_rate, + cache_write_rate, + cache_read_rate, + output_rate, + monkeypatch, ): - os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" - litellm.model_cost = litellm.get_model_cost_map(url="") + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) cached_tokens = 50000 cache_write_tokens = 40000 diff --git a/tests/test_litellm/test_gemini_3_1_flash_lite_image_pricing.py b/tests/test_litellm/test_gemini_3_1_flash_lite_image_pricing.py index adc306971e3..67d6b9e76cf 100644 --- a/tests/test_litellm/test_gemini_3_1_flash_lite_image_pricing.py +++ b/tests/test_litellm/test_gemini_3_1_flash_lite_image_pricing.py @@ -12,9 +12,6 @@ values in both the primary price map and the ``litellm/`` backup, and verify import json import os -import sys - -sys.path.insert(0, os.path.abspath("../..")) import litellm from litellm import completion_cost From a95a1d02323cd2857f7994cfc58bea428788408f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:35:52 +0000 Subject: [PATCH 18/70] fix(model_prices): drop duplicate zai-glm-5-2 entry superseded by staging Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../model_prices_and_context_window_backup.json | 16 ---------------- model_prices_and_context_window.json | 16 ---------------- 2 files changed, 32 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 06674439456..a2c51f9b952 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -49218,22 +49218,6 @@ ], "supports_audio_output": true }, - "mistral/zai-glm-5-2": { - "cache_read_input_token_cost": 1.4e-07, - "input_cost_per_token": 1.4e-06, - "litellm_provider": "mistral", - "max_input_tokens": 1000000, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 4.4e-06, - "source": "https://docs.mistral.ai/models/model-cards/zai-glm-5-2", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "fallback_generalizations": { "rules": [ { diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 06674439456..a2c51f9b952 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -49218,22 +49218,6 @@ ], "supports_audio_output": true }, - "mistral/zai-glm-5-2": { - "cache_read_input_token_cost": 1.4e-07, - "input_cost_per_token": 1.4e-06, - "litellm_provider": "mistral", - "max_input_tokens": 1000000, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 4.4e-06, - "source": "https://docs.mistral.ai/models/model-cards/zai-glm-5-2", - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_response_schema": true, - "supports_tool_choice": true - }, "fallback_generalizations": { "rules": [ { From bdd4c8e564d640931852d4f4d20c51f34a7e0768 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:14:11 +0000 Subject: [PATCH 19/70] fix(model_prices): add Gemini live-translate, Voyage 4 series, Perplexity contextualized embeddings; absorb Fireworks + Bedrock batch registry PRs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 508 +++++++++++++++++- model_prices_and_context_window.json | 508 +++++++++++++++++- .../test_bedrock_batch_pricing.py | 43 ++ 3 files changed, 1011 insertions(+), 48 deletions(-) create mode 100644 tests/test_litellm/test_bedrock_batch_pricing.py diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index a2c51f9b952..d67309aebe7 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -759,7 +759,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5e-07, + "output_cost_per_token_batches": 2.5e-06 }, "anthropic.claude-haiku-4-5@20251001": { "cache_creation_input_token_cost": 1.25e-06, @@ -2487,7 +2489,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_batches": 7.5e-06 }, "anthropic.claude-v1": { "input_cost_per_token": 8e-06, @@ -2743,7 +2747,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5.5e-07, + "output_cost_per_token_batches": 2.75e-06 }, "apac.anthropic.claude-3-sonnet-20240229-v1:0": { "deprecation_date": "2026-07-30", @@ -2839,7 +2845,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.65e-06, + "output_cost_per_token_batches": 8.25e-06 }, "azure/ada": { "input_cost_per_token": 1e-07, @@ -12452,7 +12460,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_batches": 7.5e-06 }, "claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, @@ -17032,7 +17042,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5.5e-07, + "output_cost_per_token_batches": 2.75e-06 }, "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, @@ -17255,7 +17267,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.65e-06, + "output_cost_per_token_batches": 8.25e-06 }, "eu.meta.llama3-2-1b-instruct-v1:0": { "input_cost_per_token": 1.3e-07, @@ -19861,7 +19875,7 @@ "deprecation_date": "2027-05-19", "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 1.5e-06, - "input_cost_per_audio_token": 1e-06, + "input_cost_per_audio_token": 1.5e-06, "litellm_provider": "vertex_ai", "max_input_tokens": 1048576, "max_output_tokens": 65535, @@ -19900,7 +19914,7 @@ "supports_web_search": true, "supports_native_streaming": true, "input_cost_per_token_priority": 2.7e-06, - "input_cost_per_audio_token_priority": 1.8e-06, + "input_cost_per_audio_token_priority": 2.7e-06, "output_cost_per_token_priority": 1.62e-05, "cache_read_input_token_cost_priority": 2.7e-07, "search_context_cost_per_query": { @@ -19908,7 +19922,12 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "input_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_batches": 4.5e-06, + "input_cost_per_token_flex": 7.5e-07, + "output_cost_per_token_flex": 4.5e-06, + "cache_read_input_token_cost_flex": 7.5e-08 }, "vertex_ai/gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, @@ -21593,7 +21612,7 @@ "gemini/gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 1.5e-07, - "input_cost_per_audio_token": 1e-06, + "input_cost_per_audio_token": 1.5e-06, "input_cost_per_token": 1.5e-06, "litellm_provider": "gemini", "max_input_tokens": 1048576, @@ -21635,7 +21654,7 @@ "supports_native_streaming": true, "tpm": 800000, "input_cost_per_token_priority": 2.7e-06, - "input_cost_per_audio_token_priority": 1.8e-06, + "input_cost_per_audio_token_priority": 2.7e-06, "output_cost_per_token_priority": 1.62e-05, "cache_read_input_token_cost_priority": 2.7e-07, "search_context_cost_per_query": { @@ -21643,7 +21662,12 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "input_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_batches": 4.5e-06, + "input_cost_per_token_flex": 7.5e-07, + "output_cost_per_token_flex": 4.5e-06, + "cache_read_input_token_cost_flex": 8e-08 }, "gemini/gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, @@ -21995,7 +22019,7 @@ "prompt_cache_min_tokens": 4096, "deprecation_date": "2027-05-19", "cache_read_input_token_cost": 1.5e-07, - "input_cost_per_audio_token": 1e-06, + "input_cost_per_audio_token": 1.5e-06, "input_cost_per_token": 1.5e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, @@ -22035,7 +22059,7 @@ "supports_web_search": true, "supports_native_streaming": true, "input_cost_per_token_priority": 2.7e-06, - "input_cost_per_audio_token_priority": 1.8e-06, + "input_cost_per_audio_token_priority": 2.7e-06, "output_cost_per_token_priority": 1.62e-05, "cache_read_input_token_cost_priority": 2.7e-07, "search_context_cost_per_query": { @@ -22043,7 +22067,12 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "input_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_batches": 4.5e-06, + "input_cost_per_token_flex": 7.5e-07, + "output_cost_per_token_flex": 4.5e-06, + "cache_read_input_token_cost_flex": 7.5e-08 }, "gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, @@ -23373,7 +23402,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_batches": 7.5e-06 }, "global.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -23431,7 +23462,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5e-07, + "output_cost_per_token_batches": 2.5e-06 }, "global.amazon.nova-2-lite-v1:0": { "cache_read_input_token_cost": 7.5e-08, @@ -28335,7 +28368,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.65e-06, + "output_cost_per_token_batches": 8.25e-06 }, "jp.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, @@ -28361,7 +28396,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5.5e-07, + "output_cost_per_token_batches": 2.75e-06 }, "crusoe/deepseek-ai/DeepSeek-R1-0528": { "input_cost_per_token": 3e-06, @@ -35163,7 +35200,9 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "input_cost_per_token_batches": 1.1e-07, + "output_cost_per_token_batches": 4.4e-07 }, "qwen.qwen3-coder-30b-a3b-v1:0": { "input_cost_per_token": 1.5e-07, @@ -37360,7 +37399,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5.5e-07, + "output_cost_per_token_batches": 2.75e-06 }, "us.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, @@ -37526,7 +37567,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.65e-06, + "output_cost_per_token_batches": 8.25e-06 }, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, @@ -37581,7 +37624,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5.5e-07, + "output_cost_per_token_batches": 2.75e-06 }, "us.anthropic.claude-opus-4-20250514-v1:0": { "cache_creation_input_token_cost": 1.875e-05, @@ -49274,5 +49319,420 @@ } } ] + }, + "gemini/gemini-3.5-live-translate-preview": { + "input_cost_per_audio_token": 3.5e-06, + "input_cost_per_token": 3.5e-06, + "litellm_provider": "gemini", + "mode": "chat", + "output_cost_per_audio_token": 2.1e-05, + "output_cost_per_token": 2.1e-05, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "audio" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true + }, + "perplexity/pplx-embed-context-v1-0.6b": { + "input_cost_per_token": 8e-09, + "litellm_provider": "perplexity", + "max_input_tokens": 32768, + "max_tokens": 32768, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.perplexity.ai/getting-started/pricing" + }, + "perplexity/pplx-embed-context-v1-4b": { + "input_cost_per_token": 5e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 32768, + "max_tokens": 32768, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 2560, + "source": "https://docs.perplexity.ai/getting-started/pricing" + }, + "voyage/voyage-4-large": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, + "voyage/voyage-4": { + "input_cost_per_token": 6e-08, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, + "voyage/voyage-4-lite": { + "input_cost_per_token": 2e-08, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, + "voyage/voyage-code-4": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, + "voyage/voyage-context-4": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 120000, + "max_tokens": 120000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, + "voyage/voyage-multimodal-3.5": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing", + "supports_embedding_image_input": true + }, + "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731": { + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 1.4e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/kimi-k3": { + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/deepseek-v4-flash-0731": { + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 1.4e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/glm-5p2-fast": { + "cache_read_input_token_cost": 2.1e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/glm-5p2-fast-us": { + "cache_read_input_token_cost": 2.1e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/kimi-k3": { + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/kimi-k3-fast": { + "cache_read_input_token_cost": 4.5e-07, + "input_cost_per_token": 4.5e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.25e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/kimi-k3-us": { + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/qwen3p8-max": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "fireworks_ai", + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/muse-glimmer-30b": { + "cache_read_input_token_cost": 4e-08, + "input_cost_per_token": 3.5e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/nemotron-lightning-3p5-30b-a3b": { + "cache_read_input_token_cost": 1e-08, + "input_cost_per_token": 5e-08, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/nemotron-3-ultra-nvfp4": { + "cache_read_input_token_cost": 1.2e-07, + "input_cost_per_token": 6e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/muse-glimmer-30b": { + "cache_read_input_token_cost": 4e-08, + "input_cost_per_token": 3.5e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b": { + "cache_read_input_token_cost": 1e-08, + "input_cost_per_token": 5e-08, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/nemotron-3-ultra-nvfp4": { + "cache_read_input_token_cost": 1.2e-07, + "input_cost_per_token": 6e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/qwen3p8-max": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "fireworks_ai", + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/accounts/fireworks/routers/glm-5p2-fast": { + "cache_read_input_token_cost": 2.1e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/routers/glm-5p2-fast-us": { + "cache_read_input_token_cost": 2.1e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/routers/kimi-k3-fast": { + "cache_read_input_token_cost": 4.5e-07, + "input_cost_per_token": 4.5e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.25e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/accounts/fireworks/routers/kimi-k3-us": { + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true } } diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index a2c51f9b952..d67309aebe7 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -759,7 +759,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5e-07, + "output_cost_per_token_batches": 2.5e-06 }, "anthropic.claude-haiku-4-5@20251001": { "cache_creation_input_token_cost": 1.25e-06, @@ -2487,7 +2489,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_batches": 7.5e-06 }, "anthropic.claude-v1": { "input_cost_per_token": 8e-06, @@ -2743,7 +2747,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5.5e-07, + "output_cost_per_token_batches": 2.75e-06 }, "apac.anthropic.claude-3-sonnet-20240229-v1:0": { "deprecation_date": "2026-07-30", @@ -2839,7 +2845,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.65e-06, + "output_cost_per_token_batches": 8.25e-06 }, "azure/ada": { "input_cost_per_token": 1e-07, @@ -12452,7 +12460,9 @@ "supports_tool_choice": true, "supports_vision": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_batches": 7.5e-06 }, "claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, @@ -17032,7 +17042,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5.5e-07, + "output_cost_per_token_batches": 2.75e-06 }, "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, @@ -17255,7 +17267,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.65e-06, + "output_cost_per_token_batches": 8.25e-06 }, "eu.meta.llama3-2-1b-instruct-v1:0": { "input_cost_per_token": 1.3e-07, @@ -19861,7 +19875,7 @@ "deprecation_date": "2027-05-19", "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 1.5e-06, - "input_cost_per_audio_token": 1e-06, + "input_cost_per_audio_token": 1.5e-06, "litellm_provider": "vertex_ai", "max_input_tokens": 1048576, "max_output_tokens": 65535, @@ -19900,7 +19914,7 @@ "supports_web_search": true, "supports_native_streaming": true, "input_cost_per_token_priority": 2.7e-06, - "input_cost_per_audio_token_priority": 1.8e-06, + "input_cost_per_audio_token_priority": 2.7e-06, "output_cost_per_token_priority": 1.62e-05, "cache_read_input_token_cost_priority": 2.7e-07, "search_context_cost_per_query": { @@ -19908,7 +19922,12 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "input_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_batches": 4.5e-06, + "input_cost_per_token_flex": 7.5e-07, + "output_cost_per_token_flex": 4.5e-06, + "cache_read_input_token_cost_flex": 7.5e-08 }, "vertex_ai/gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, @@ -21593,7 +21612,7 @@ "gemini/gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, "cache_read_input_token_cost": 1.5e-07, - "input_cost_per_audio_token": 1e-06, + "input_cost_per_audio_token": 1.5e-06, "input_cost_per_token": 1.5e-06, "litellm_provider": "gemini", "max_input_tokens": 1048576, @@ -21635,7 +21654,7 @@ "supports_native_streaming": true, "tpm": 800000, "input_cost_per_token_priority": 2.7e-06, - "input_cost_per_audio_token_priority": 1.8e-06, + "input_cost_per_audio_token_priority": 2.7e-06, "output_cost_per_token_priority": 1.62e-05, "cache_read_input_token_cost_priority": 2.7e-07, "search_context_cost_per_query": { @@ -21643,7 +21662,12 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "input_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_batches": 4.5e-06, + "input_cost_per_token_flex": 7.5e-07, + "output_cost_per_token_flex": 4.5e-06, + "cache_read_input_token_cost_flex": 8e-08 }, "gemini/gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, @@ -21995,7 +22019,7 @@ "prompt_cache_min_tokens": 4096, "deprecation_date": "2027-05-19", "cache_read_input_token_cost": 1.5e-07, - "input_cost_per_audio_token": 1e-06, + "input_cost_per_audio_token": 1.5e-06, "input_cost_per_token": 1.5e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1048576, @@ -22035,7 +22059,7 @@ "supports_web_search": true, "supports_native_streaming": true, "input_cost_per_token_priority": 2.7e-06, - "input_cost_per_audio_token_priority": 1.8e-06, + "input_cost_per_audio_token_priority": 2.7e-06, "output_cost_per_token_priority": 1.62e-05, "cache_read_input_token_cost_priority": 2.7e-07, "search_context_cost_per_query": { @@ -22043,7 +22067,12 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "input_cost_per_token_batches": 7.5e-07, + "output_cost_per_token_batches": 4.5e-06, + "input_cost_per_token_flex": 7.5e-07, + "output_cost_per_token_flex": 4.5e-06, + "cache_read_input_token_cost_flex": 7.5e-08 }, "gemini-3.6-flash": { "prompt_cache_min_tokens": 4096, @@ -23373,7 +23402,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.5e-06, + "output_cost_per_token_batches": 7.5e-06 }, "global.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, @@ -23431,7 +23462,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5e-07, + "output_cost_per_token_batches": 2.5e-06 }, "global.amazon.nova-2-lite-v1:0": { "cache_read_input_token_cost": 7.5e-08, @@ -28335,7 +28368,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.65e-06, + "output_cost_per_token_batches": 8.25e-06 }, "jp.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, @@ -28361,7 +28396,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5.5e-07, + "output_cost_per_token_batches": 2.75e-06 }, "crusoe/deepseek-ai/DeepSeek-R1-0528": { "input_cost_per_token": 3e-06, @@ -35163,7 +35200,9 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_native_structured_output": true + "supports_native_structured_output": true, + "input_cost_per_token_batches": 1.1e-07, + "output_cost_per_token_batches": 4.4e-07 }, "qwen.qwen3-coder-30b-a3b-v1:0": { "input_cost_per_token": 1.5e-07, @@ -37360,7 +37399,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5.5e-07, + "output_cost_per_token_batches": 2.75e-06 }, "us.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, @@ -37526,7 +37567,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 1024, + "input_cost_per_token_batches": 1.65e-06, + "output_cost_per_token_batches": 8.25e-06 }, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, @@ -37581,7 +37624,9 @@ "supports_vision": true, "supports_native_structured_output": true, "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 4096 + "prompt_cache_min_tokens": 4096, + "input_cost_per_token_batches": 5.5e-07, + "output_cost_per_token_batches": 2.75e-06 }, "us.anthropic.claude-opus-4-20250514-v1:0": { "cache_creation_input_token_cost": 1.875e-05, @@ -49274,5 +49319,420 @@ } } ] + }, + "gemini/gemini-3.5-live-translate-preview": { + "input_cost_per_audio_token": 3.5e-06, + "input_cost_per_token": 3.5e-06, + "litellm_provider": "gemini", + "mode": "chat", + "output_cost_per_audio_token": 2.1e-05, + "output_cost_per_token": 2.1e-05, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "audio" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true + }, + "perplexity/pplx-embed-context-v1-0.6b": { + "input_cost_per_token": 8e-09, + "litellm_provider": "perplexity", + "max_input_tokens": 32768, + "max_tokens": 32768, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.perplexity.ai/getting-started/pricing" + }, + "perplexity/pplx-embed-context-v1-4b": { + "input_cost_per_token": 5e-08, + "litellm_provider": "perplexity", + "max_input_tokens": 32768, + "max_tokens": 32768, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 2560, + "source": "https://docs.perplexity.ai/getting-started/pricing" + }, + "voyage/voyage-4-large": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, + "voyage/voyage-4": { + "input_cost_per_token": 6e-08, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, + "voyage/voyage-4-lite": { + "input_cost_per_token": 2e-08, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, + "voyage/voyage-code-4": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, + "voyage/voyage-context-4": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 120000, + "max_tokens": 120000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing" + }, + "voyage/voyage-multimodal-3.5": { + "input_cost_per_token": 1.2e-07, + "litellm_provider": "voyage", + "max_input_tokens": 32000, + "max_tokens": 32000, + "mode": "embedding", + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "source": "https://docs.voyageai.com/docs/pricing", + "supports_embedding_image_input": true + }, + "fireworks_ai/accounts/fireworks/models/deepseek-v4-flash-0731": { + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 1.4e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/kimi-k3": { + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/deepseek-v4-flash-0731": { + "cache_read_input_token_cost": 2.8e-08, + "input_cost_per_token": 1.4e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/glm-5p2-fast": { + "cache_read_input_token_cost": 2.1e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/glm-5p2-fast-us": { + "cache_read_input_token_cost": 2.1e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/kimi-k3": { + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.5e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/kimi-k3-fast": { + "cache_read_input_token_cost": 4.5e-07, + "input_cost_per_token": 4.5e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.25e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/kimi-k3-us": { + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/qwen3p8-max": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "fireworks_ai", + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/muse-glimmer-30b": { + "cache_read_input_token_cost": 4e-08, + "input_cost_per_token": 3.5e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/nemotron-lightning-3p5-30b-a3b": { + "cache_read_input_token_cost": 1e-08, + "input_cost_per_token": 5e-08, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/nemotron-3-ultra-nvfp4": { + "cache_read_input_token_cost": 1.2e-07, + "input_cost_per_token": 6e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/muse-glimmer-30b": { + "cache_read_input_token_cost": 4e-08, + "input_cost_per_token": 3.5e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b": { + "cache_read_input_token_cost": 1e-08, + "input_cost_per_token": 5e-08, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2e-07, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/nemotron-3-ultra-nvfp4": { + "cache_read_input_token_cost": 1.2e-07, + "input_cost_per_token": 6e-07, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/models/qwen3p8-max": { + "cache_read_input_token_cost": 2.5e-07, + "input_cost_per_token": 2e-06, + "litellm_provider": "fireworks_ai", + "mode": "chat", + "output_cost_per_token": 6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/accounts/fireworks/routers/glm-5p2-fast": { + "cache_read_input_token_cost": 2.1e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/routers/glm-5p2-fast-us": { + "cache_read_input_token_cost": 2.1e-07, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.6e-06, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "fireworks_ai/accounts/fireworks/routers/kimi-k3-fast": { + "cache_read_input_token_cost": 4.5e-07, + "input_cost_per_token": 4.5e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 2.25e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "fireworks_ai/accounts/fireworks/routers/kimi-k3-us": { + "cache_read_input_token_cost": 3.3e-07, + "input_cost_per_token": 3.3e-06, + "litellm_provider": "fireworks_ai", + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.65e-05, + "source": "https://docs.fireworks.ai/serverless/pricing", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true } } diff --git a/tests/test_litellm/test_bedrock_batch_pricing.py b/tests/test_litellm/test_bedrock_batch_pricing.py new file mode 100644 index 00000000000..856085ec253 --- /dev/null +++ b/tests/test_litellm/test_bedrock_batch_pricing.py @@ -0,0 +1,43 @@ +import json +from pathlib import Path + +import pytest + +PRICING_FILES = ( + "model_prices_and_context_window.json", + "litellm/model_prices_and_context_window_backup.json", +) + +BEDROCK_BATCH_MODELS = ( + "qwen.qwen3-235b-a22b-2507-v1:0", + "anthropic.claude-haiku-4-5-20251001-v1:0", + "apac.anthropic.claude-haiku-4-5-20251001-v1:0", + "au.anthropic.claude-haiku-4-5-20251001-v1:0", + "eu.anthropic.claude-haiku-4-5-20251001-v1:0", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "jp.anthropic.claude-haiku-4-5-20251001-v1:0", + "us.anthropic.claude-haiku-4-5-20251001-v1:0", + "anthropic.claude-sonnet-4-5-20250929-v1:0", + "au.anthropic.claude-sonnet-4-5-20250929-v1:0", + "claude-sonnet-4-5-20250929-v1:0", + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", + "global.anthropic.claude-sonnet-4-5-20250929-v1:0", + "jp.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", +) + + +@pytest.mark.parametrize("pricing_file", PRICING_FILES) +@pytest.mark.parametrize("model", BEDROCK_BATCH_MODELS) +def test_bedrock_batch_pricing_is_half_of_on_demand( + pricing_file: str, model: str +) -> None: + model_cost_map = json.loads((Path(__file__).parents[2] / pricing_file).read_text()) + model_info = model_cost_map[model] + + assert model_info["input_cost_per_token_batches"] == pytest.approx( + model_info["input_cost_per_token"] / 2 + ) + assert model_info["output_cost_per_token_batches"] == pytest.approx( + model_info["output_cost_per_token"] / 2 + ) From ba4a355afc9dc52e38170bae524feebbb1408640 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:27:44 +0000 Subject: [PATCH 20/70] fix(model_prices): add tpm/rpm to gemini live-translate preview entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/model_prices_and_context_window_backup.json | 4 +++- model_prices_and_context_window.json | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d67309aebe7..94c40fc3a88 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -49327,6 +49327,7 @@ "mode": "chat", "output_cost_per_audio_token": 2.1e-05, "output_cost_per_token": 2.1e-05, + "rpm": 10, "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/realtime" @@ -49338,7 +49339,8 @@ "audio" ], "supports_audio_input": true, - "supports_audio_output": true + "supports_audio_output": true, + "tpm": 250000 }, "perplexity/pplx-embed-context-v1-0.6b": { "input_cost_per_token": 8e-09, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d67309aebe7..94c40fc3a88 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -49327,6 +49327,7 @@ "mode": "chat", "output_cost_per_audio_token": 2.1e-05, "output_cost_per_token": 2.1e-05, + "rpm": 10, "source": "https://ai.google.dev/gemini-api/docs/pricing", "supported_endpoints": [ "/v1/realtime" @@ -49338,7 +49339,8 @@ "audio" ], "supports_audio_input": true, - "supports_audio_output": true + "supports_audio_output": true, + "tpm": 250000 }, "perplexity/pplx-embed-context-v1-0.6b": { "input_cost_per_token": 8e-09, From abc6ebfb3358122541a9fa795d42bb92d3db76f1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:50:40 -0700 Subject: [PATCH 21/70] fix(responses_bridge): map incomplete responses to finish_reason length instead of 500 --- .../transformation.py | 119 ++++++--- ...responses_transformation_transformation.py | 237 ++++++++++++++++++ 2 files changed, 328 insertions(+), 28 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 5f3e9ac753c..7a95ab6ac28 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -113,6 +113,48 @@ def _build_reasoning_item( } +def _reasoning_item_from_output_item(item: object) -> _BuiltReasoningItem | None: + from openai.types.responses import ResponseReasoningItem + + if isinstance(item, ResponseReasoningItem): + return _build_reasoning_item( + item_id=item.id, + encrypted_content=getattr(item, "encrypted_content", None), + summary_raw=item.summary, + ) + if isinstance(item, dict) and item.get("type") == "reasoning": + return _build_reasoning_item( + item_id=item.get("id", ""), + encrypted_content=item.get("encrypted_content"), + summary_raw=item.get("summary"), + ) + return None + + +def _reasoning_items_from_output_items(output_items: Sequence[object]) -> tuple[_BuiltReasoningItem, ...]: + return tuple( + reasoning_item + for reasoning_item in (_reasoning_item_from_output_item(item) for item in output_items) + if reasoning_item is not None + ) + + +def _map_incomplete_reason_to_finish_reason(incomplete_reason: str | None) -> Literal["length", "content_filter"]: + if incomplete_reason == "content_filter": + return "content_filter" + return "length" + + +def _incomplete_reason_from_response_payload(response_payload: object) -> str | None: + if not isinstance(response_payload, Mapping): + return None + incomplete_details: Final = response_payload.get("incomplete_details") + if not isinstance(incomplete_details, Mapping): + return None + reason: Final = incomplete_details.get("reason") + return reason if isinstance(reason, str) else None + + class _ChatToolCallDict(ChatCompletionToolCallChunk, total=False): provider_specific_fields: Mapping[str, object] @@ -657,6 +699,30 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): return choices + @staticmethod + def _build_empty_incomplete_choice( + output_items: Sequence[object], + finish_reason: Literal["length", "content_filter"], + ) -> "Choices": + from litellm.types.utils import Choices, Message + + reasoning_items: Final = _reasoning_items_from_output_items(output_items) + reasoning_content: Final = " ".join( + summary_block["text"] + for reasoning_item in reasoning_items + for summary_block in reasoning_item["summary"] + if summary_block.get("text") + ) + message: Final = Message( + content="", + reasoning_content=reasoning_content if reasoning_content else None, + reasoning_items=cast( + list[ChatCompletionReasoningItem] | None, + reasoning_items or None, + ), + ) + return Choices(message=message, finish_reason=finish_reason, index=0) + @classmethod def _extract_output_from_completed_event(cls, parsed_chunk: Mapping[str, object]) -> list[dict[str, object]] | None: response_payload: Final = parsed_chunk.get("response") @@ -763,11 +829,22 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): handle_raw_dict_callback=self._handle_raw_dict_response_item, ) - if len(choices) == 0: - if raw_response.incomplete_details is not None and raw_response.incomplete_details.reason is not None: - raise ValueError(f"{model} unable to complete request: {raw_response.incomplete_details.reason}") + response_is_incomplete: Final = ( + raw_response.status == "incomplete" or raw_response.incomplete_details is not None + ) + + if len(choices) == 0 and not response_is_incomplete: + raise ValueError(f"Unknown items in responses API response: {output_items}") + + if response_is_incomplete: + incomplete_finish_reason: Final = _map_incomplete_reason_to_finish_reason( + raw_response.incomplete_details.reason if raw_response.incomplete_details is not None else None + ) + if len(choices) == 0: + choices.append(self._build_empty_incomplete_choice(output_items, incomplete_finish_reason)) else: - raise ValueError(f"Unknown items in responses API response: {output_items}") + for choice in choices: + choice.finish_reason = incomplete_finish_reason setattr(model_response, "choices", choices) @@ -1392,12 +1469,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): ) ] ) - elif event_type == "response.completed": - # Response is fully complete - now we can signal is_finished=True - # This ensures we don't prematurely end the stream before tool_calls arrive - - # Check if response contains function_call items in output - # to determine correct finish_reason + elif event_type in ("response.completed", "response.incomplete"): response_data: Final = parsed_chunk.get("response", {}) output_items: Final = response_data.get("output", []) if response_data else [] @@ -1407,25 +1479,16 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): if isinstance(item, dict) ) - finish_reason: Final = "tool_calls" if has_function_calls else "stop" + finish_reason: Final = ( + _map_incomplete_reason_to_finish_reason(_incomplete_reason_from_response_payload(response_data)) + if event_type == "response.incomplete" + else ("tool_calls" if has_function_calls else "stop") + ) - # Extract reasoning items with encrypted_content for round-tripping - completed_reasoning_items: list[_BuiltReasoningItem] | None = None - for item in output_items: - if not isinstance(item, dict) or item.get("type") != "reasoning": - continue - if completed_reasoning_items is None: - completed_reasoning_items = [] - completed_reasoning_items.append( - _build_reasoning_item( - item_id=item.get("id", ""), - encrypted_content=item.get("encrypted_content"), - summary_raw=item.get("summary"), - ) - ) - completed_reasoning_items_typed: Final = cast( + terminal_reasoning_items: Final = _reasoning_items_from_output_items(output_items) + terminal_reasoning_items_typed: Final = cast( list[ChatCompletionReasoningItem] | None, - completed_reasoning_items, + list(terminal_reasoning_items) if terminal_reasoning_items else None, ) usage = None @@ -1439,7 +1502,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): index=0, delta=Delta( content="", - reasoning_items=completed_reasoning_items_typed, + reasoning_items=terminal_reasoning_items_typed, ), finish_reason=finish_reason, ) diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 5508931b35d..6b0c82c9a46 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -3485,3 +3485,240 @@ async def test_acompletion_bridge_normalizes_tool_choice_on_the_wire( post_kwargs = mock_post.call_args.kwargs request_body = post_kwargs["json"] if "json" in post_kwargs else json.loads(post_kwargs["data"]) assert request_body["tool_choice"] == expected_wire_tool_choice + + +def _make_incomplete_responses_api_response(incomplete_reason, output): + from litellm.types.llms.openai import ( + InputTokensDetails, + OutputTokensDetails, + ResponseAPIUsage, + ResponsesAPIResponse, + ) + + return ResponsesAPIResponse( + id="resp_incomplete", + created_at=1760144904, + error=None, + incomplete_details={"reason": incomplete_reason} if incomplete_reason else None, + instructions=None, + metadata={}, + model="gpt-5.6-sol", + object="response", + output=output, + parallel_tool_calls=True, + temperature=1.0, + tool_choice="auto", + tools=[], + top_p=1.0, + max_output_tokens=16, + previous_response_id=None, + reasoning={"effort": "high", "summary": None}, + status="incomplete", + text={"format": {"type": "text"}, "verbosity": "medium"}, + truncation="disabled", + usage=ResponseAPIUsage( + input_tokens=37, + input_tokens_details=InputTokensDetails( + audio_tokens=None, cached_tokens=0, text_tokens=None + ), + output_tokens=16, + output_tokens_details=OutputTokensDetails( + reasoning_tokens=16, text_tokens=None + ), + total_tokens=53, + cost=None, + ), + user=None, + store=True, + background=False, + billing={"payer": "developer"}, + max_tool_calls=None, + prompt_cache_key=None, + safety_identifier=None, + service_tier="default", + top_logprobs=0, + ) + + +def _make_reasoning_only_output_item(): + from openai.types.responses.response_reasoning_item import ResponseReasoningItem + + return ResponseReasoningItem( + id="rs_incomplete", + summary=[], + type="reasoning", + content=None, + encrypted_content="enc_abc", + status=None, + ) + + +def _call_transform_response(handler, raw_response): + logging_obj = Mock() + logging_obj.model_call_details = {} + return handler.transform_response( + model="gpt-5.6-sol", + raw_response=raw_response, + model_response=_make_empty_model_response(), + logging_obj=logging_obj, + request_data={"model": "gpt-5.6-sol"}, + messages=[{"role": "user", "content": "compute something hard"}], + optional_params={}, + litellm_params={}, + encoding=Mock(), + ) + + +def test_transform_response_incomplete_reasoning_only_returns_empty_length_choice(): + handler = LiteLLMResponsesTransformationHandler() + raw_response = _make_incomplete_responses_api_response( + "max_output_tokens", [_make_reasoning_only_output_item()] + ) + + result = _call_transform_response(handler, raw_response) + + assert len(result.choices) == 1 + choice = result.choices[0] + assert choice.finish_reason == "length" + assert choice.index == 0 + assert choice.message.role == "assistant" + assert choice.message.content == "" + assert choice.message.reasoning_items[0]["encrypted_content"] == "enc_abc" + assert result.usage.prompt_tokens == 37 + assert result.usage.completion_tokens == 16 + assert result.usage.total_tokens == 53 + assert result.usage.completion_tokens_details.reasoning_tokens == 16 + + +def test_transform_response_incomplete_content_filter_maps_finish_reason(): + handler = LiteLLMResponsesTransformationHandler() + raw_response = _make_incomplete_responses_api_response( + "content_filter", [_make_reasoning_only_output_item()] + ) + + result = _call_transform_response(handler, raw_response) + + assert len(result.choices) == 1 + assert result.choices[0].finish_reason == "content_filter" + assert result.choices[0].message.content == "" + + +def test_transform_response_zero_choices_not_incomplete_still_raises(): + handler = LiteLLMResponsesTransformationHandler() + raw_response = _make_empty_responses_api_response() + + with pytest.raises(ValueError, match="Unknown items"): + _call_transform_response(handler, raw_response) + + +def test_transform_response_incomplete_partial_text_overrides_finish_reason_to_length(): + from openai.types.responses import ResponseOutputMessage, ResponseOutputText + + handler = LiteLLMResponsesTransformationHandler() + output_message = ResponseOutputMessage( + id="msg_partial", + content=[ + ResponseOutputText( + annotations=[], text="partial answer", type="output_text", logprobs=[] + ) + ], + role="assistant", + status="incomplete", + type="message", + ) + raw_response = _make_incomplete_responses_api_response( + "max_output_tokens", [_make_reasoning_only_output_item(), output_message] + ) + + result = _call_transform_response(handler, raw_response) + + assert len(result.choices) == 1 + choice = result.choices[0] + assert choice.finish_reason == "length" + assert choice.message.content == "partial answer" + + +def test_response_incomplete_stream_event_emits_length_and_usage(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + + iterator = OpenAiResponsesToChatCompletionStreamIterator( + streaming_response=None, sync_stream=True + ) + + chunk = { + "type": "response.incomplete", + "response": { + "id": "resp_123", + "status": "incomplete", + "incomplete_details": {"reason": "max_output_tokens"}, + "output": [ + { + "type": "reasoning", + "id": "rs_1", + "encrypted_content": "enc_abc", + "summary": [], + } + ], + "usage": { + "input_tokens": 37, + "output_tokens": 16, + "output_tokens_details": {"reasoning_tokens": 16}, + "total_tokens": 53, + }, + }, + } + + result = iterator.chunk_parser(chunk) + + assert len(result.choices) == 1 + assert result.choices[0].finish_reason == "length" + assert result.choices[0].delta.reasoning_items[0]["encrypted_content"] == "enc_abc" + assert result.usage is not None + assert result.usage.prompt_tokens == 37 + assert result.usage.completion_tokens == 16 + assert result.usage.total_tokens == 53 + + +def test_response_incomplete_stream_event_content_filter_maps_finish_reason(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + + iterator = OpenAiResponsesToChatCompletionStreamIterator( + streaming_response=None, sync_stream=True + ) + + chunk = { + "type": "response.incomplete", + "response": { + "id": "resp_123", + "status": "incomplete", + "incomplete_details": {"reason": "content_filter"}, + "output": [], + }, + } + + result = iterator.chunk_parser(chunk) + + assert result.choices[0].finish_reason == "content_filter" + + +def test_response_incomplete_stream_event_without_details_defaults_to_length(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + + iterator = OpenAiResponsesToChatCompletionStreamIterator( + streaming_response=None, sync_stream=True + ) + + chunk = { + "type": "response.incomplete", + "response": {"id": "resp_123", "status": "incomplete", "output": []}, + } + + result = iterator.chunk_parser(chunk) + + assert result.choices[0].finish_reason == "length" From 50a346da1cac1756bc78254ad046e52f094f1a2c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:54:03 -0700 Subject: [PATCH 22/70] fix(model_prices): restore supports_vision on Mistral Small 4.0 entries --- ...odel_prices_and_context_window_backup.json | 6 ++- model_prices_and_context_window.json | 6 ++- .../test_mistral_small_4_0_model_metadata.py | 49 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 tests/test_litellm/test_mistral_small_4_0_model_metadata.py diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 94c40fc3a88..9b1d354e02f 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -30223,7 +30223,8 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_reasoning": true + "supports_reasoning": true, + "supports_vision": true }, "mistral/mistral-small-3-2-2506": { "deprecation_date": "2026-07-31", @@ -49192,7 +49193,8 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "mistral/labs-leanstral-1-5": { "input_cost_per_token": 0.0, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 94c40fc3a88..9b1d354e02f 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -30223,7 +30223,8 @@ "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_reasoning": true + "supports_reasoning": true, + "supports_vision": true }, "mistral/mistral-small-3-2-2506": { "deprecation_date": "2026-07-31", @@ -49192,7 +49193,8 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "mistral/labs-leanstral-1-5": { "input_cost_per_token": 0.0, diff --git a/tests/test_litellm/test_mistral_small_4_0_model_metadata.py b/tests/test_litellm/test_mistral_small_4_0_model_metadata.py new file mode 100644 index 00000000000..0442321ba0b --- /dev/null +++ b/tests/test_litellm/test_mistral_small_4_0_model_metadata.py @@ -0,0 +1,49 @@ +import json +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parents[2] +MAIN_PATH = REPO_ROOT / "model_prices_and_context_window.json" +BACKUP_PATH = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json" + +SMALL_4_0_MODELS = ( + "mistral/mistral-small-latest", + "mistral/mistral-small-2603", +) + + +def _load(path): + with open(path) as f: + return json.load(f) + + +@pytest.mark.parametrize("model", SMALL_4_0_MODELS) +def test_small_4_0_specs(model): + info = _load(MAIN_PATH).get(model) + assert info is not None, f"{model} missing from model_prices_and_context_window.json" + + assert info["litellm_provider"] == "mistral" + assert info["mode"] == "chat" + + assert info["input_cost_per_token"] == 1.5e-07 + assert info["output_cost_per_token"] == 6e-07 + + assert info["max_input_tokens"] == 262144 + assert info["max_output_tokens"] == 262144 + assert info["max_tokens"] == 262144 + + assert info["supports_reasoning"] is True + assert info["supports_vision"] is True + assert info["supports_function_calling"] is True + assert info["supports_response_schema"] is True + assert info["supports_tool_choice"] is True + assert info["supports_assistant_prefill"] is True + + +@pytest.mark.parametrize("model", SMALL_4_0_MODELS) +def test_backup_matches_main(model): + main_cost = _load(MAIN_PATH) + backup_cost = _load(BACKUP_PATH) + + assert backup_cost.get(model) == main_cost.get(model), f"{model} differs between main and backup model cost maps" From c551a5c44abacea6d777cdd7bedde075a9dd75c9 Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 20 Aug 2026 20:59:38 +0000 Subject: [PATCH 23/70] fix(proxy): treat explicit zero non-token prices as priced A deployment that overrides any cost_per field, including at zero, now counts as priced so it is not blocked as unpriced Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_checks.py | 13 +++++++---- .../proxy/auth/test_auth_checks.py | 23 ++++++++++++++++++- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 3639ef245cf..5ad61f93d4f 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -300,15 +300,20 @@ def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: return False +def _entry_declares_price(entry: Mapping[str, object]) -> bool: + return any("cost_per" in key for key in entry) + + def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: """ - Check every deployment behind a model group for a positive price on any billed - metric (tokens, characters, seconds, pages, images, queries, ...), so models that - are billed by a non-token metric are not treated as unpriced. + A model group counts as priced when a deployment overrides any *cost_per* field in its + litellm_params, even at zero, or when its resolved model info carries a positive price on + any billed metric (tokens, characters, seconds, pages, images, queries, ...), so models + billed by a non-token metric are not treated as unpriced. """ for deployment in llm_router.get_model_list(model_name=model) or []: litellm_params = deployment.get("litellm_params") or {} - if _entry_has_priced_metric(litellm_params): + if _entry_declares_price(litellm_params): return True model_id = (deployment.get("model_info") or {}).get("id") diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index a078e041aa7..fbdd9a42750 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -5246,7 +5246,7 @@ def test_model_has_no_cost_mapping_non_token_price_from_litellm_params_is_false( { "model_name": "custom-tts", "litellm_params": { - "model": UNPRICED_UNDERLYING_MODEL, + "model": f"{UNPRICED_UNDERLYING_MODEL}-per-second", "api_key": "sk-test", "input_cost_per_second": 0.0001, }, @@ -5257,6 +5257,27 @@ def test_model_has_no_cost_mapping_non_token_price_from_litellm_params_is_false( assert model_has_no_cost_mapping(model="custom-tts", llm_router=router) is False +@pytest.mark.parametrize("cost_field", ["input_cost_per_second", "input_cost_per_token"]) +def test_model_has_no_cost_mapping_explicit_zero_price_is_false(cost_field): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "free-group", + "litellm_params": { + "model": f"{UNPRICED_UNDERLYING_MODEL}-{cost_field}", + "api_key": "sk-test", + cost_field: 0, + }, + } + ] + ) + + assert model_has_no_cost_mapping(model="free-group", llm_router=router) is False + + async def _run_common_checks( model: Optional[str], llm_router: Optional["Router"], route: str = "/chat/completions" ) -> bool: From 21891b44837b17427f4a54067aad9f4f756ea6b0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:32:54 -0700 Subject: [PATCH 24/70] fix(proxy): count explicit zero prices on any billed metric as configured pricing --- litellm/proxy/auth/auth_checks.py | 24 +++++++++++++---- .../cost_tracking_settings.py | 26 ++++++++++++------- .../proxy/auth/test_auth_checks.py | 21 +++++++++++++++ 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 5de3f9624aa..6f41fde4b88 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -445,7 +445,9 @@ def _has_ptu_flat_cost(model: str, llm_router: "Router") -> bool: def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: """ Check if any deployment in the model group has cost fields explicitly - set in its litellm.model_cost entry. + set in its litellm_params or its litellm.model_cost entry, on any billed + metric. An explicit zero counts: pricing a model at 0 is a deliberate + admin choice, distinct from a model missing from the cost map. When Router._create_deployment() registers a model not in the global cost map, it creates a sparse entry like {"id": ""} with no cost @@ -455,6 +457,8 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: for deployment in llm_router.model_list: if deployment.get("model_name") != model: continue + if _entry_has_explicit_cost_key(deployment.get("litellm_params") or _EMPTY_COST_ENTRY): + return True model_id = deployment.get("model_info", {}).get("id") if model_id is None: continue @@ -464,10 +468,20 @@ def _is_cost_explicitly_configured(model: str, llm_router: "Router") -> bool: return False +_EMPTY_COST_ENTRY: Final[Mapping[str, object]] = MappingProxyType({}) + + def _is_positive_cost(value: object) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 +def _entry_has_explicit_cost_key(entry: Mapping[str, object]) -> bool: + return any( + "cost_per" in key and isinstance(value, (int, float)) and not isinstance(value, bool) + for key, value in entry.items() + ) + + def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: for key, value in entry.items(): if "cost_per" not in key: @@ -485,12 +499,12 @@ def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: metric (tokens, characters, seconds, pages, images, queries, ...), so models that are billed by a non-token metric are not treated as unpriced. """ - for deployment in llm_router.get_model_list(model_name=model) or []: - litellm_params = deployment.get("litellm_params") or {} + for deployment in llm_router.get_model_list(model_name=model) or (): + litellm_params = deployment.get("litellm_params") or _EMPTY_COST_ENTRY if _entry_has_priced_metric(litellm_params): return True - model_id = (deployment.get("model_info") or {}).get("id") + model_id = (deployment.get("model_info") or _EMPTY_COST_ENTRY).get("id") if model_id is None: continue @@ -503,7 +517,7 @@ def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: return False -def model_has_no_cost_mapping(model: Optional[str], llm_router: Optional[Router]) -> bool: +def model_has_no_cost_mapping(model: str | None, llm_router: Router | None) -> bool: if not model or llm_router is None: return False diff --git a/litellm/proxy/management_endpoints/cost_tracking_settings.py b/litellm/proxy/management_endpoints/cost_tracking_settings.py index 444ffa434b3..842a6c54f33 100644 --- a/litellm/proxy/management_endpoints/cost_tracking_settings.py +++ b/litellm/proxy/management_endpoints/cost_tracking_settings.py @@ -450,8 +450,8 @@ class BlockUnpricedModelsResponse(BaseModel): @router.get( "/config/block_requests_for_models_without_pricing", - tags=["Cost Tracking"], - dependencies=[Depends(user_api_key_auth)], + tags=("Cost Tracking",), + dependencies=(Depends(user_api_key_auth),), response_model=BlockUnpricedModelsResponse, ) async def get_block_requests_for_models_without_pricing() -> BlockUnpricedModelsResponse: @@ -460,8 +460,8 @@ async def get_block_requests_for_models_without_pricing() -> BlockUnpricedModels @router.patch( "/config/block_requests_for_models_without_pricing", - tags=["Cost Tracking"], - dependencies=[Depends(user_api_key_auth)], + tags=("Cost Tracking",), + dependencies=(Depends(user_api_key_auth),), response_model=BlockUnpricedModelsResponse, ) async def update_block_requests_for_models_without_pricing( @@ -476,19 +476,23 @@ async def update_block_requests_for_models_without_pricing( if prisma_client is None: raise HTTPException( status_code=500, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, + detail={ # mutable-ok: HTTPException detail must be a plain mapping + "error": CommonProxyErrors.db_not_connected_error.value + }, ) if store_model_in_db is not True: raise HTTPException( status_code=500, - detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."}, + detail={ # mutable-ok: HTTPException detail must be a plain mapping + "error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature." + }, ) try: config = await proxy_config.get_config() if "litellm_settings" not in config: - config["litellm_settings"] = {} + config["litellm_settings"] = {} # mutable-ok: config is a plain-dict payload for save_config config["litellm_settings"]["block_requests_for_models_without_pricing"] = request.enabled await proxy_config.save_config(new_config=config) @@ -496,11 +500,13 @@ async def update_block_requests_for_models_without_pricing( verbose_proxy_logger.info(f"Updated block_requests_for_models_without_pricing: {request.enabled}") return BlockUnpricedModelsResponse(enabled=request.enabled) - except Exception as e: - verbose_proxy_logger.error(f"Error updating block_requests_for_models_without_pricing: {str(e)}") + except Exception as e: # noqa: BLE001 # any config persistence failure must surface as a 500 response, not a crash + verbose_proxy_logger.error(f"Error updating block_requests_for_models_without_pricing: {e!s}") raise HTTPException( status_code=500, - detail={"error": f"Failed to update setting: {str(e)}"}, + detail={ # mutable-ok: HTTPException detail must be a plain mapping + "error": f"Failed to update setting: {e!s}" + }, ) diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index ce633699748..11a982472a8 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -6646,6 +6646,27 @@ def test_model_has_no_cost_mapping_non_token_price_from_litellm_params_is_false( assert model_has_no_cost_mapping(model="custom-tts", llm_router=router) is False +@pytest.mark.parametrize("zero_cost_key", ["input_cost_per_second", "input_cost_per_token"]) +def test_model_has_no_cost_mapping_explicit_zero_price_is_false(zero_cost_key): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "free-group", + "litellm_params": { + "model": UNPRICED_UNDERLYING_MODEL, + "api_key": "sk-test", + zero_cost_key: 0.0, + }, + } + ] + ) + + assert model_has_no_cost_mapping(model="free-group", llm_router=router) is False + + async def _run_common_checks( model: Optional[str], llm_router: Optional["Router"], route: str = "/chat/completions" ) -> bool: From 2996a18fa9843324d2c46dd0f6975a76003524f9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:33:04 -0700 Subject: [PATCH 25/70] fix(model_prices): document 262k input limit on fireworks qwen3p8-max --- litellm/model_prices_and_context_window_backup.json | 2 ++ model_prices_and_context_window.json | 2 ++ 2 files changed, 4 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 9b1d354e02f..3d987463564 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -49557,6 +49557,7 @@ "cache_read_input_token_cost": 2.5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 6e-06, "source": "https://docs.fireworks.ai/serverless/pricing", @@ -49666,6 +49667,7 @@ "cache_read_input_token_cost": 2.5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 6e-06, "source": "https://docs.fireworks.ai/serverless/pricing", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9b1d354e02f..3d987463564 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -49557,6 +49557,7 @@ "cache_read_input_token_cost": 2.5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 6e-06, "source": "https://docs.fireworks.ai/serverless/pricing", @@ -49666,6 +49667,7 @@ "cache_read_input_token_cost": 2.5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "fireworks_ai", + "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 6e-06, "source": "https://docs.fireworks.ai/serverless/pricing", From ab79b8dcb6a027dbb93b33b424e1b6b3a5814c4d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:52:37 -0700 Subject: [PATCH 26/70] fix: count tiered_pricing as a cost mapping when blocking unpriced models --- litellm/proxy/auth/auth_checks.py | 12 ++++---- .../proxy/auth/test_auth_checks.py | 28 ++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index ede1b1a0a03..0bf419ca1de 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -472,6 +472,8 @@ def _is_positive_cost(value: object) -> bool: def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: + if entry.get("tiered_pricing") is not None: + return True for key, value in entry.items(): if "cost_per" not in key: continue @@ -483,15 +485,15 @@ def _entry_has_priced_metric(entry: Mapping[str, object]) -> bool: def _entry_declares_price(entry: Mapping[str, object]) -> bool: - return any("cost_per" in key for key in entry) + return any("cost_per" in key or key == "tiered_pricing" for key in entry) def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: """ - A model group counts as priced when a deployment overrides any *cost_per* field in its - litellm_params, even at zero, or when its resolved model info carries a positive price on - any billed metric (tokens, characters, seconds, pages, images, queries, ...), so models - billed by a non-token metric are not treated as unpriced. + A model group counts as priced when a deployment overrides any *cost_per* field or + tiered_pricing in its litellm_params, even at zero, or when its resolved model info carries + tiered_pricing or a positive price on any billed metric (tokens, characters, seconds, pages, + images, queries, ...), so models billed by a non-token metric are not treated as unpriced. """ for deployment in llm_router.get_model_list(model_name=model) or (): litellm_params = deployment.get("litellm_params") or _EMPTY_COST_ENTRY diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index a1b8f2efaae..12ab090fe47 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -3,9 +3,12 @@ import json import os import sys from types import SimpleNamespace -from typing import Optional +from typing import TYPE_CHECKING, Optional from unittest.mock import AsyncMock, MagicMock, patch +if TYPE_CHECKING: + from litellm.router import Router + sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path @@ -6667,6 +6670,29 @@ def test_model_has_no_cost_mapping_explicit_zero_price_is_false(cost_field): assert model_has_no_cost_mapping(model="free-group", llm_router=router) is False +def test_model_has_no_cost_mapping_tiered_pricing_only_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + from litellm.router import Router + + router = Router( + model_list=[ + { + "model_name": "tiered-group", + "litellm_params": { + "model": f"{UNPRICED_UNDERLYING_MODEL}-tiered", + "api_key": "sk-test", + "tiered_pricing": [ + {"range": [0, 128000], "input_cost_per_token": 2e-7, "output_cost_per_token": 6e-7}, + {"range": [128000, 256000], "input_cost_per_token": 4e-7, "output_cost_per_token": 12e-7}, + ], + }, + } + ] + ) + + assert model_has_no_cost_mapping(model="tiered-group", llm_router=router) is False + + async def _run_common_checks( model: Optional[str], llm_router: Optional["Router"], route: str = "/chat/completions" ) -> bool: From eb8d4021873382a64f911abb7ce560780068607d Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 20 Aug 2026 21:55:43 +0000 Subject: [PATCH 27/70] test(proxy): cover a registry model priced only via tiered_pricing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/proxy/auth/test_auth_checks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 12ab090fe47..b8b50cddb1e 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -6611,6 +6611,7 @@ def test_model_has_no_cost_mapping_no_model_or_router_is_false(): "azure/speech/azure-tts", "mistral/mistral-ocr-latest", "vertex_ai/imagen-3.0-generate-001", + "dashscope/qwen-flash", ], ) def test_model_has_no_cost_mapping_non_token_priced_model_is_false(underlying_model): From 2b2d6d7aad8b8fc4b62a685171a1a38cfbf810ed Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:35:20 -0700 Subject: [PATCH 28/70] fix(proxy): apply DB-persisted safe litellm settings on every worker's config reload Peer workers previously kept their startup value for block_requests_for_models_without_pricing until a restart, so a toggle from the UI only took effect on the worker that served the request. --- litellm/proxy/proxy_server.py | 13 ++++++++++ .../test_cost_tracking_settings.py | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 12d0f13ebdd..36033f0ff30 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -6823,6 +6823,19 @@ class ProxyConfig: if self._should_load_db_object(object_type="config_overrides"): await self._init_hashicorp_vault_config_override(prisma_client=prisma_client) + await self._apply_safe_litellm_settings_overrides_from_db(prisma_client=prisma_client) + + async def _apply_safe_litellm_settings_overrides_from_db(self, prisma_client: PrismaClient) -> None: + config_record: Final = await get_config_param(prisma_client, "litellm_settings") + if config_record is None or config_record.param_value is None: + return + raw_settings: Final = config_record.param_value + litellm_settings: Final = json.loads(raw_settings) if isinstance(raw_settings, str) else raw_settings + if not isinstance(litellm_settings, dict): + return + for key, value in litellm_settings.items(): + if key in LITELLM_SETTINGS_SAFE_DB_OVERRIDES: + setattr(litellm, key, value) async def _init_semantic_filter_settings_in_db(self, prisma_client: PrismaClient): """ diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index ff80bbe4938..8dfc83760b7 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -740,6 +740,32 @@ class TestBlockRequestsForModelsWithoutPricing: assert litellm.block_requests_for_models_without_pricing is True + @pytest.mark.asyncio + async def test_periodic_db_sync_applies_flag_to_peer_worker(self): + """The ~10s reconcile loop runs _init_non_llm_objects_in_db on every worker; it must apply + the persisted flag so peers converge without a restart.""" + from types import SimpleNamespace + + from litellm.proxy.proxy_server import ProxyConfig + + config_record = SimpleNamespace( + param_value={"block_requests_for_models_without_pricing": True, "unsafe_key": "x"} + ) + with ( + patch.object(litellm, "block_requests_for_models_without_pricing", False), + patch.object( + ProxyConfig, + "_should_load_db_object", + side_effect=lambda object_type: object_type == "config_overrides", + ), + patch.object(ProxyConfig, "_init_hashicorp_vault_config_override", AsyncMock()), + patch("litellm.proxy.proxy_server.get_config_param", AsyncMock(return_value=config_record)), + ): + await ProxyConfig()._init_non_llm_objects_in_db(prisma_client=MagicMock()) + + assert litellm.block_requests_for_models_without_pricing is True + assert not hasattr(litellm, "unsafe_key") + @pytest.mark.asyncio async def test_patch_requires_store_model_in_db(self): with ( From 3672fa9fb5a212809edb0660009a5e1f8180c840 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:38:27 -0700 Subject: [PATCH 29/70] fix(proxy): log block_requests_for_models_without_pricing updates lazily The eager f-strings tripped tests/test_litellm/test_logging.py::test_logging_calls_do_not_build_their_message_eagerly. --- litellm/proxy/management_endpoints/cost_tracking_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/cost_tracking_settings.py b/litellm/proxy/management_endpoints/cost_tracking_settings.py index 842a6c54f33..204051c3715 100644 --- a/litellm/proxy/management_endpoints/cost_tracking_settings.py +++ b/litellm/proxy/management_endpoints/cost_tracking_settings.py @@ -497,11 +497,11 @@ async def update_block_requests_for_models_without_pricing( await proxy_config.save_config(new_config=config) litellm.block_requests_for_models_without_pricing = request.enabled - verbose_proxy_logger.info(f"Updated block_requests_for_models_without_pricing: {request.enabled}") + verbose_proxy_logger.info("Updated block_requests_for_models_without_pricing: %s", request.enabled) return BlockUnpricedModelsResponse(enabled=request.enabled) except Exception as e: # noqa: BLE001 # any config persistence failure must surface as a 500 response, not a crash - verbose_proxy_logger.error(f"Error updating block_requests_for_models_without_pricing: {e!s}") + verbose_proxy_logger.error("Error updating block_requests_for_models_without_pricing: %s", e) raise HTTPException( status_code=500, detail={ # mutable-ok: HTTPException detail must be a plain mapping From a3b676278869f171863b1fb96e742249ff76f841 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:39:08 -0700 Subject: [PATCH 30/70] fix(streaming): price partial-stream spend rows at the real model and keep prompt and cache fields A streaming chat completion that ends early (client disconnect, or the proxy cutting the stream at LITELLM_MAX_STREAMING_DURATION_SECONDS) wrote a spend log row with spend 0.0, prompt_tokens 0 on the proxy-cut path, and no cache fields in usage_object. The proxy restamps chunk.model in place to the client-facing alias, so the partial response rebuilt from those chunks priced the unmapped alias and came out at 0. The failure path also rebuilt usage without the request messages, so prompt tokens counted to 0, and a cut stream never sees the final usage event that normally zero-fills the cache fields. Restamp the rebuilt partial response with the wrapper's real model before cost calculation on both the disconnect and the failure paths, pass the request messages when rebuilding usage on the failure path, and zero-fill missing cache usage fields the way completed streams already do. --- .../litellm_core_utils/streaming_handler.py | 19 +++- litellm/proxy/common_request_processing.py | 9 ++ .../test_streaming_handler.py | 104 ++++++++++++++++++ .../proxy/test_common_request_processing.py | 85 ++++++++++++++ 4 files changed, 216 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 485091bccd0..3565872f09e 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -2315,10 +2315,18 @@ class CustomStreamWrapper: if self.logging_obj is None or not self.chunks: return try: - partial_response: Final = litellm.stream_chunk_builder(chunks=self.chunks) + partial_response: Final = litellm.stream_chunk_builder( + chunks=self.chunks, + messages=self.messages if isinstance(self.messages, list) else None, + ) + if partial_response is None: + return usage: Final = cast(Usage | None, getattr(partial_response, "usage", None)) if usage is None: return + if self.model: + partial_response.model = self.model + zero_fill_missing_cache_usage_fields(usage) self.logging_obj.model_call_details["combined_usage_object"] = usage self.logging_obj.model_call_details["response_cost"] = ( self.logging_obj._response_cost_calculator(result=partial_response) or 0.0 @@ -2439,6 +2447,15 @@ class CustomStreamWrapper: return chunk +def zero_fill_missing_cache_usage_fields(usage: Usage) -> None: + if getattr(usage, "cache_creation_input_tokens", None) is None: + usage.cache_creation_input_tokens = 0 # rebind-ok: in-place zero-fill is the contract + if getattr(usage, "cache_read_input_tokens", None) is None: + usage.cache_read_input_tokens = 0 # rebind-ok: in-place zero-fill is the contract + if usage.prompt_tokens_details is None: + usage.prompt_tokens_details = PromptTokensDetailsWrapper(cached_tokens=0) # rebind-ok: zero-fill in place + + _TokenDetails = TypeVar("_TokenDetails", PromptTokensDetailsWrapper, CompletionTokensDetailsWrapper) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index a0b69ecb0bf..d430d776350 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -43,6 +43,9 @@ from litellm.litellm_core_utils.llm_response_utils.get_headers import ( get_response_headers, ) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.litellm_core_utils.streaming_handler import ( + zero_fill_missing_cache_usage_fields, +) from litellm.proxy._types import ProxyException, UserAPIKeyAuth from litellm.proxy.auth.auth_checks import can_key_call_resolved_model from litellm.proxy.auth.auth_utils import check_response_size_is_safe @@ -324,6 +327,12 @@ async def _bill_partial_streamed_spend_on_disconnect(request_data: dict, respons return False if partial_response is None: return False + wrapper_model: Final = getattr(response, "model", None) + if isinstance(wrapper_model, str) and wrapper_model: + partial_response.model = wrapper_model + partial_usage: Final = getattr(partial_response, "usage", None) + if isinstance(partial_usage, Usage): + zero_fill_missing_cache_usage_fields(partial_usage) try: await logging_obj.dispatch_success_handlers( partial_response, diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py index 05b44fffbc5..a550160cd73 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py @@ -3388,6 +3388,110 @@ def test_record_partial_usage_for_failure_noop_without_chunks(): assert "combined_usage_object" not in logging_obj.model_call_details +def _wrapper_with_partial_chunks( + chunk_model: str, + usage: Optional[Usage] = None, + model: str = "gpt-4o-mini", + custom_llm_provider: str = "openai", +) -> tuple: + logging_obj = Logging( + model=model, + messages=[{"role": "user", "content": "Tell me a long story"}], + stream=True, + call_type="completion", + start_time=time.time(), + litellm_call_id="partial-usage-alias", + function_id="1245", + ) + logging_obj.model_call_details["custom_llm_provider"] = custom_llm_provider + logging_obj.optional_params = {} + wrapper = CustomStreamWrapper( + completion_stream=None, + model=model, + logging_obj=logging_obj, + custom_llm_provider=custom_llm_provider, + ) + wrapper.chunks = [ + ModelResponseStream( + id="chatcmpl-partial-alias-1", + created=1742056047, + model=chunk_model, + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta( + content="The Roman Empire began when", role="assistant" + ), + ) + ], + usage=usage, + ) + ] + return wrapper, logging_obj + + +def test_record_partial_usage_for_failure_prices_alias_restamped_chunks_at_real_model(): + wrapper, logging_obj = _wrapper_with_partial_chunks( + chunk_model="bedrock-claude-opus-5", + usage=Usage(prompt_tokens=40, completion_tokens=5, total_tokens=45), + model="us.anthropic.claude-opus-5", + custom_llm_provider="bedrock", + ) + assert "bedrock/bedrock-claude-opus-5" not in litellm.model_cost + + wrapper._record_partial_usage_for_failure() + + stashed = logging_obj.model_call_details["combined_usage_object"] + assert stashed.completion_tokens == 5 + rates = litellm.model_cost["us.anthropic.claude-opus-5"] + expected = 40 * rates["input_cost_per_token"] + 5 * rates["output_cost_per_token"] + assert logging_obj.model_call_details["response_cost"] == pytest.approx(expected) + + +def test_record_partial_usage_for_failure_counts_prompt_tokens_from_request_messages(): + wrapper, logging_obj = _wrapper_with_partial_chunks(chunk_model="my-public-alias") + + wrapper._record_partial_usage_for_failure() + + stashed = logging_obj.model_call_details["combined_usage_object"] + assert stashed.prompt_tokens > 0 + + +def test_record_partial_usage_for_failure_zero_fills_missing_cache_fields(): + wrapper, logging_obj = _wrapper_with_partial_chunks(chunk_model="gpt-4o-mini") + + wrapper._record_partial_usage_for_failure() + + stashed = logging_obj.model_call_details["combined_usage_object"] + assert stashed.cache_creation_input_tokens == 0 + assert stashed.cache_read_input_tokens == 0 + assert stashed.prompt_tokens_details is not None + assert stashed.prompt_tokens_details.cached_tokens == 0 + + +def test_record_partial_usage_for_failure_keeps_cache_values_recovered_from_chunks(): + recovered = Usage( + prompt_tokens=40, + completion_tokens=5, + total_tokens=45, + cache_read_input_tokens=7, + cache_creation_input_tokens=3, + ) + wrapper, logging_obj = _wrapper_with_partial_chunks( + chunk_model="gpt-4o-mini", usage=recovered + ) + + wrapper._record_partial_usage_for_failure() + + stashed = logging_obj.model_call_details["combined_usage_object"] + assert stashed.cache_read_input_tokens == 7 + assert stashed.cache_creation_input_tokens == 3 + assert stashed.prompt_tokens_details is not None + assert stashed.prompt_tokens_details.cached_tokens == 7 + + @pytest.mark.parametrize("sync_mode", [True, False]) @pytest.mark.asyncio async def test_stream_chunk_builder_raise_at_end_of_stream_still_recovers_usage( diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 510fb977a61..609d0dfe1b6 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -5519,6 +5519,91 @@ class TestStreamingClientDisconnectBilling: proxy_logging_obj._arelease_max_parallel_requests_on_disconnect.assert_awaited_once() + async def _bill_and_collect_success_event(self, prepare=None): + recorder = _RecordingSuccessLogger() + original_callbacks = litellm.callbacks + litellm.callbacks = [recorder] + try: + response = await self._start_partial_stream() + if prepare is not None: + prepare(response) + billed = await _bill_partial_streamed_spend_on_disconnect( + {"litellm_logging_obj": response.logging_obj}, response + ) + assert billed is True + for _ in range(50): + if recorder.success_events: + break + await asyncio.sleep(0.1) + finally: + litellm.callbacks = original_callbacks + assert len(recorder.success_events) == 1 + return recorder.success_events[0] + + @pytest.mark.asyncio + async def test_disconnect_billing_prices_alias_restamped_chunks_at_real_model(self): + assert "openai/my-public-alias" not in litellm.model_cost + + def restamp_chunks_to_alias(response): + for chunk in response.chunks: + chunk.model = "my-public-alias" + + event = await self._bill_and_collect_success_event(restamp_chunks_to_alias) + + assert event["response_obj"].model == "gpt-4o-mini" + standard_logging_object = event["kwargs"]["standard_logging_object"] + assert standard_logging_object["response_cost"] > 0.0 + + @pytest.mark.asyncio + async def test_disconnect_billing_zero_fills_missing_cache_fields(self): + event = await self._bill_and_collect_success_event() + + usage = event["response_obj"].usage + assert getattr(usage, "cache_creation_input_tokens", None) == 0 + assert getattr(usage, "cache_read_input_tokens", None) == 0 + assert usage.prompt_tokens_details is not None + assert usage.prompt_tokens_details.cached_tokens == 0 + + @pytest.mark.asyncio + async def test_disconnect_billing_keeps_cache_values_recovered_from_chunks(self): + from litellm.types.utils import ( + Delta, + ModelResponseStream, + StreamingChoices, + Usage, + ) + + def append_usage_chunk(response): + response.chunks.append( + ModelResponseStream( + id=response.chunks[0].id, + model="gpt-4o-mini", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta(content=" and more", role="assistant"), + ) + ], + usage=Usage( + prompt_tokens=40, + completion_tokens=5, + total_tokens=45, + cache_read_input_tokens=7, + cache_creation_input_tokens=3, + ), + ) + ) + + event = await self._bill_and_collect_success_event(append_usage_chunk) + + usage = event["response_obj"].usage + assert getattr(usage, "cache_read_input_tokens", None) == 7 + assert getattr(usage, "cache_creation_input_tokens", None) == 3 + assert usage.prompt_tokens_details is not None + assert usage.prompt_tokens_details.cached_tokens == 7 + def _apply_stream_usage_tracking( data: dict, From 3c44f8d9269600b913256e2372a456888abe6f4e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:47:13 -0700 Subject: [PATCH 31/70] fix(ui): surface toggle failures on the block-unpriced-models setting The hook swallowed errors into the console, so an admin flipping the switch without STORE_MODEL_IN_DB saw nothing happen and got no reason why. Adds the missing hook tests. --- .../use_block_unpriced_config.test.ts | 108 ++++++++++++++++++ .../_components/use_block_unpriced_config.ts | 2 + 2 files changed, 110 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.test.ts new file mode 100644 index 00000000000..1daf7583b4b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useBlockUnpricedConfig } from "./use_block_unpriced_config"; +import { apiClient } from "@/components/networking"; +import { toast } from "@/lib/toast"; + +vi.mock("@/components/networking", () => ({ + apiClient: { + get: vi.fn(), + patch: vi.fn(), + }, +})); + +const ENDPOINT = "/config/block_requests_for_models_without_pricing"; + +describe("useBlockUnpricedConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("fetchBlockUnpriced", () => { + it("reflects the enabled flag returned by the proxy", async () => { + vi.mocked(apiClient.get).mockResolvedValueOnce({ enabled: true }); + + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: "test-token" })); + + await act(async () => { + await result.current.fetchBlockUnpriced(); + }); + + expect(apiClient.get).toHaveBeenCalledWith(ENDPOINT, { accessToken: "test-token" }); + expect(result.current.blockUnpriced).toBe(true); + }); + + it("surfaces a toast when the fetch throws", async () => { + const error = new Error("Network error"); + vi.mocked(apiClient.get).mockRejectedValueOnce(error); + + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: "test-token" })); + + await act(async () => { + await result.current.fetchBlockUnpriced(); + }); + + expect(toast.fromError).toHaveBeenCalledWith(error); + expect(result.current.blockUnpriced).toBe(false); + }); + + it("does nothing without an access token", async () => { + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: null })); + + await act(async () => { + await result.current.fetchBlockUnpriced(); + }); + + expect(apiClient.get).not.toHaveBeenCalled(); + }); + }); + + describe("setBlockUnpriced", () => { + it("persists the new value and confirms it with a toast", async () => { + vi.mocked(apiClient.patch).mockResolvedValueOnce({ enabled: true }); + + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: "test-token" })); + + await act(async () => { + await result.current.setBlockUnpriced(true); + }); + + expect(apiClient.patch).toHaveBeenCalledWith(ENDPOINT, { + accessToken: "test-token", + body: { enabled: true }, + }); + expect(result.current.blockUnpriced).toBe(true); + expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/will now be blocked/i)); + expect(result.current.isUpdating).toBe(false); + }); + + it("confirms turning the block back off", async () => { + vi.mocked(apiClient.patch).mockResolvedValueOnce({ enabled: false }); + + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: "test-token" })); + + await act(async () => { + await result.current.setBlockUnpriced(false); + }); + + expect(result.current.blockUnpriced).toBe(false); + expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/now allowed/i)); + }); + + it("surfaces the proxy error and leaves the flag unchanged when the update fails", async () => { + const error = new Error("Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."); + vi.mocked(apiClient.patch).mockRejectedValueOnce(error); + + const { result } = renderHook(() => useBlockUnpricedConfig({ accessToken: "test-token" })); + + await act(async () => { + await result.current.setBlockUnpriced(true); + }); + + expect(toast.fromError).toHaveBeenCalledWith(error); + expect(toast.success).not.toHaveBeenCalled(); + expect(result.current.blockUnpriced).toBe(false); + expect(result.current.isUpdating).toBe(false); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts index 5383f7350ad..4bf9d5ddacc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-tracking/_components/use_block_unpriced_config.ts @@ -30,6 +30,7 @@ export function useBlockUnpricedConfig({ accessToken }: UseBlockUnpricedConfigPr setBlockUnpricedState(Boolean(data?.enabled)); } catch (error) { console.error("Error fetching block-unpriced-models setting:", error); + toast.fromError(error); } }, [accessToken]); @@ -47,6 +48,7 @@ export function useBlockUnpricedConfig({ accessToken }: UseBlockUnpricedConfigPr ); } catch (error) { console.error("Error updating block-unpriced-models setting:", error); + toast.fromError(error); } finally { setIsUpdating(false); } From df00c334d156d0aee8dbb381eac1a8caa12fe7ab Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 20 Aug 2026 22:53:34 +0000 Subject: [PATCH 32/70] fix(proxy): reload the unpriced-model toggle regardless of supported_db_objects Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/proxy_server.py | 3 ++- .../management_endpoints/test_cost_tracking_settings.py | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 36033f0ff30..db3bb52984a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -6823,7 +6823,8 @@ class ProxyConfig: if self._should_load_db_object(object_type="config_overrides"): await self._init_hashicorp_vault_config_override(prisma_client=prisma_client) - await self._apply_safe_litellm_settings_overrides_from_db(prisma_client=prisma_client) + + await self._apply_safe_litellm_settings_overrides_from_db(prisma_client=prisma_client) async def _apply_safe_litellm_settings_overrides_from_db(self, prisma_client: PrismaClient) -> None: config_record: Final = await get_config_param(prisma_client, "litellm_settings") diff --git a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py index 8dfc83760b7..ea86731eba4 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py +++ b/tests/test_litellm/proxy/management_endpoints/test_cost_tracking_settings.py @@ -741,9 +741,11 @@ class TestBlockRequestsForModelsWithoutPricing: assert litellm.block_requests_for_models_without_pricing is True @pytest.mark.asyncio - async def test_periodic_db_sync_applies_flag_to_peer_worker(self): + @pytest.mark.parametrize("loads_config_overrides", [True, False]) + async def test_periodic_db_sync_applies_flag_to_peer_worker(self, loads_config_overrides): """The ~10s reconcile loop runs _init_non_llm_objects_in_db on every worker; it must apply - the persisted flag so peers converge without a restart.""" + the persisted flag so peers converge without a restart, including when supported_db_objects + leaves config_overrides out.""" from types import SimpleNamespace from litellm.proxy.proxy_server import ProxyConfig @@ -756,7 +758,7 @@ class TestBlockRequestsForModelsWithoutPricing: patch.object( ProxyConfig, "_should_load_db_object", - side_effect=lambda object_type: object_type == "config_overrides", + side_effect=lambda object_type: loads_config_overrides and object_type == "config_overrides", ), patch.object(ProxyConfig, "_init_hashicorp_vault_config_override", AsyncMock()), patch("litellm.proxy.proxy_server.get_config_param", AsyncMock(return_value=config_record)), From 47731303b53a2bebd1ded4a115edb3901ab8aee8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:09:30 -0700 Subject: [PATCH 33/70] fix(caching): bound the semantic cache embedding lookup A semantic cache lookup embeds the prompt before the request reaches the LLM, and that embedding call carried no deadline of its own. It inherited the 6000s request timeout and the Router's num_retries, so an embedding endpoint that is down or unroutable parked every proxied request for minutes and gave back nothing but x-litellm-semantic-similarity 0.0 once it finally gave up. The lookup now runs on its own short deadline, 5s by default, with retries off so failures cannot stack. Redis, Valkey and qdrant all pick it up, and the deadline is settable per cache with semantic_cache_embedding_timeout or globally with SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS. --- litellm/caching/_embedding_router.py | 8 + litellm/caching/caching.py | 5 + litellm/caching/qdrant_semantic_cache.py | 34 +++- litellm/caching/redis_semantic_cache.py | 43 +++-- litellm/caching/valkey_semantic_cache.py | 3 + litellm/constants.py | 4 + litellm/main.py | 6 +- .../caching/test_redis_semantic_cache.py | 154 ++++++++++++++++++ 8 files changed, 233 insertions(+), 24 deletions(-) diff --git a/litellm/caching/_embedding_router.py b/litellm/caching/_embedding_router.py index 8dfcddf158a..cec25634bb8 100644 --- a/litellm/caching/_embedding_router.py +++ b/litellm/caching/_embedding_router.py @@ -16,6 +16,7 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Final import litellm +from litellm.constants import SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS if TYPE_CHECKING: from litellm.router import Router @@ -60,6 +61,13 @@ def resolve_embedding_max_input_tokens( return deployment_max_input_tokens +def resolve_embedding_timeout(configured_timeout: float | None) -> float: + """Explicit cache setting first, else the short semantic-cache default.""" + if configured_timeout is not None: + return configured_timeout + return SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS + + def truncate_embedding_input(prompt: str, embedding_model: str, max_input_tokens: int | None) -> str: """Keep only the first ``max_input_tokens`` tokens of ``prompt`` for the embedding call.""" if max_input_tokens is None: diff --git a/litellm/caching/caching.py b/litellm/caching/caching.py index 6b68ae98111..cefe6aae9ed 100644 --- a/litellm/caching/caching.py +++ b/litellm/caching/caching.py @@ -98,6 +98,7 @@ class Cache: qdrant_semantic_cache_embedding_model: str = "text-embedding-ada-002", qdrant_semantic_cache_vector_size: int | None = None, semantic_cache_embedding_max_input_tokens: int | None = None, + semantic_cache_embedding_timeout: float | None = None, # GCP IAM authentication parameters gcp_service_account: str | None = None, gcp_ssl_ca_certs: str | None = None, @@ -124,6 +125,7 @@ class Cache: qdrant_collection_name (str, optional): The name for your qdrant collection. Required if type is "qdrant-semantic". similarity_threshold (float, optional): The similarity threshold for semantic-caching, Required if type is "redis-semantic" or "qdrant-semantic". semantic_cache_embedding_max_input_tokens (int, optional): Truncate prompts to this many tokens before embedding them for semantic caching. Defaults to the embedding deployment's configured max_input_tokens. + semantic_cache_embedding_timeout (float, optional): Seconds a semantic-cache lookup may spend embedding the prompt before it gives up and lets the request continue to the LLM. Defaults to SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS. # Disk Cache Args disk_cache_dir (str, optional): The directory for the disk cache. Defaults to None. @@ -195,6 +197,7 @@ class Cache: embedding_model=redis_semantic_cache_embedding_model, index_name=redis_semantic_cache_index_name, embedding_max_input_tokens=semantic_cache_embedding_max_input_tokens, + embedding_timeout=semantic_cache_embedding_timeout, **kwargs, ) elif type == LiteLLMCacheType.VALKEY_SEMANTIC: @@ -211,6 +214,7 @@ class Cache: index_name=valkey_semantic_cache_index_name, startup_nodes=redis_startup_nodes, embedding_max_input_tokens=semantic_cache_embedding_max_input_tokens, + embedding_timeout=semantic_cache_embedding_timeout, **kwargs, ) elif type == LiteLLMCacheType.QDRANT_SEMANTIC: @@ -223,6 +227,7 @@ class Cache: embedding_model=qdrant_semantic_cache_embedding_model, vector_size=qdrant_semantic_cache_vector_size, embedding_max_input_tokens=semantic_cache_embedding_max_input_tokens, + embedding_timeout=semantic_cache_embedding_timeout, ) elif type == LiteLLMCacheType.LOCAL: self.cache = InMemoryCache() diff --git a/litellm/caching/qdrant_semantic_cache.py b/litellm/caching/qdrant_semantic_cache.py index 8270c655d82..4898700c403 100644 --- a/litellm/caching/qdrant_semantic_cache.py +++ b/litellm/caching/qdrant_semantic_cache.py @@ -16,7 +16,11 @@ from typing import TYPE_CHECKING, Any, Final, cast import litellm from litellm._logging import print_verbose -from litellm.constants import QDRANT_SCALAR_QUANTILE, QDRANT_VECTOR_SIZE +from litellm.constants import ( + QDRANT_SCALAR_QUANTILE, + QDRANT_VECTOR_SIZE, + SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS, +) from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_str_from_messages, ) @@ -26,6 +30,7 @@ from ._embedding_router import ( build_router_embedding_metadata, resolve_embedding_max_input_tokens, resolve_embedding_router, + resolve_embedding_timeout, truncate_embedding_input, ) from .base_cache import BaseCache @@ -37,6 +42,7 @@ if TYPE_CHECKING: class QdrantSemanticCache(BaseCache): CACHE_KEY_FIELD_NAME = "litellm_cache_key" embedding_max_input_tokens: int | None = None + embedding_timeout: float = SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS def __init__( self, @@ -49,6 +55,7 @@ class QdrantSemanticCache(BaseCache): host_type=None, vector_size=None, embedding_max_input_tokens: int | None = None, + embedding_timeout: float | None = None, ): from litellm.llms.custom_httpx.http_handler import ( _get_httpx_client, @@ -68,6 +75,7 @@ class QdrantSemanticCache(BaseCache): self.similarity_threshold = similarity_threshold self.embedding_model = embedding_model self.embedding_max_input_tokens = embedding_max_input_tokens + self.embedding_timeout = resolve_embedding_timeout(embedding_timeout) self.vector_size = vector_size if vector_size is not None else QDRANT_VECTOR_SIZE headers = {} @@ -222,11 +230,15 @@ class QdrantSemanticCache(BaseCache): input=embedding_input, cache={"no-store": True, "no-cache": True}, metadata=build_router_embedding_metadata(metadata), + timeout=self.embedding_timeout, + num_retries=0, ) return litellm.embedding( model=self.embedding_model, input=embedding_input, cache={"no-store": True, "no-cache": True}, + timeout=self.embedding_timeout, + num_retries=0, ) async def _get_async_embedding(self, prompt: str, metadata: dict[str, Any] | None = None) -> EmbeddingResponse: @@ -238,19 +250,25 @@ class QdrantSemanticCache(BaseCache): router: Final = resolve_embedding_router(self.embedding_model, llm_router, llm_model_list) embedding_input: Final = self._embedding_input(prompt, router) - if router is not None: - return await router.aembedding( + embedding_call: Final = ( + router.aembedding( model=self.embedding_model, input=embedding_input, cache={"no-store": True, "no-cache": True}, metadata=build_router_embedding_metadata(metadata), + timeout=self.embedding_timeout, + num_retries=0, + ) + if router is not None + else litellm.aembedding( + model=self.embedding_model, + input=embedding_input, + cache={"no-store": True, "no-cache": True}, + timeout=self.embedding_timeout, + num_retries=0, ) - - return await litellm.aembedding( - model=self.embedding_model, - input=embedding_input, - cache={"no-store": True, "no-cache": True}, ) + return await asyncio.wait_for(embedding_call, self.embedding_timeout) def set_cache(self, key, value, **kwargs): print_verbose(f"qdrant semantic-cache set_cache, kwargs: {kwargs}") diff --git a/litellm/caching/redis_semantic_cache.py b/litellm/caching/redis_semantic_cache.py index d91260f4d9c..f5264e28124 100644 --- a/litellm/caching/redis_semantic_cache.py +++ b/litellm/caching/redis_semantic_cache.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Any, Final, cast import litellm from litellm._logging import print_verbose, verbose_logger +from litellm.constants import SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS from litellm.litellm_core_utils.prompt_templates.common_utils import ( get_str_from_messages, ) @@ -27,6 +28,7 @@ from ._embedding_router import ( build_router_embedding_metadata, resolve_embedding_max_input_tokens, resolve_embedding_router, + resolve_embedding_timeout, truncate_embedding_input, ) from .base_cache import BaseCache @@ -47,6 +49,7 @@ class RedisSemanticCache(BaseCache): DEFAULT_REDIS_INDEX_NAME: str = "litellm_semantic_cache_index" CACHE_KEY_FIELD_NAME: str = "litellm_cache_key" embedding_max_input_tokens: int | None = None + embedding_timeout: float = SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS def __init__( self, @@ -58,6 +61,7 @@ class RedisSemanticCache(BaseCache): embedding_model: str = "text-embedding-ada-002", index_name: str | None = None, embedding_max_input_tokens: int | None = None, + embedding_timeout: float | None = None, **kwargs: object, ): """ @@ -74,6 +78,8 @@ class RedisSemanticCache(BaseCache): index_name: Name for the Redis index embedding_max_input_tokens: Truncate prompts to this many tokens before embedding; defaults to the Router deployment's configured max_input_tokens + embedding_timeout: Seconds a cache lookup may spend embedding the prompt before it + gives up and lets the request continue to the LLM ttl: Default time-to-live for cache entries in seconds **kwargs: Additional arguments passed to the Redis client @@ -99,6 +105,7 @@ class RedisSemanticCache(BaseCache): self.distance_threshold = 1 - similarity_threshold self.embedding_model = embedding_model self.embedding_max_input_tokens = embedding_max_input_tokens + self.embedding_timeout = resolve_embedding_timeout(embedding_timeout) # Set up Redis connection if redis_url is None: @@ -349,6 +356,8 @@ class RedisSemanticCache(BaseCache): input=embedding_input, cache={"no-store": True, "no-cache": True}, metadata=build_router_embedding_metadata(metadata), + timeout=self.embedding_timeout, + num_retries=0, ), ) else: @@ -358,6 +367,8 @@ class RedisSemanticCache(BaseCache): model=self.embedding_model, input=embedding_input, cache={"no-store": True, "no-cache": True}, + timeout=self.embedding_timeout, + num_retries=0, ), ) return embedding_response["data"][0]["embedding"] @@ -512,20 +523,26 @@ class RedisSemanticCache(BaseCache): router: Final = resolve_embedding_router(self.embedding_model, llm_router, llm_model_list) embedding_input: Final = self._embedding_input(prompt, router) + embedding_call: Final = ( + router.aembedding( + model=self.embedding_model, + input=embedding_input, + cache={"no-store": True, "no-cache": True}, + metadata=build_router_embedding_metadata(metadata), + timeout=self.embedding_timeout, + num_retries=0, + ) + if router is not None + else litellm.aembedding( + model=self.embedding_model, + input=embedding_input, + cache={"no-store": True, "no-cache": True}, + timeout=self.embedding_timeout, + num_retries=0, + ) + ) try: - if router is not None: - embedding_response = await router.aembedding( - model=self.embedding_model, - input=embedding_input, - cache={"no-store": True, "no-cache": True}, - metadata=build_router_embedding_metadata(metadata), - ) - else: - embedding_response = await litellm.aembedding( - model=self.embedding_model, - input=embedding_input, - cache={"no-store": True, "no-cache": True}, - ) + embedding_response: Final = await asyncio.wait_for(embedding_call, self.embedding_timeout) return embedding_response["data"][0]["embedding"] except Exception as e: print_verbose(f"Error generating async embedding: {e}") diff --git a/litellm/caching/valkey_semantic_cache.py b/litellm/caching/valkey_semantic_cache.py index 737d212a89d..c66f6873383 100644 --- a/litellm/caching/valkey_semantic_cache.py +++ b/litellm/caching/valkey_semantic_cache.py @@ -30,6 +30,7 @@ from litellm._logging import print_verbose from litellm._uuid import uuid from litellm.llms.valkey.common_utils import build_valkey_url, pack_vector +from ._embedding_router import resolve_embedding_timeout from .redis_semantic_cache import RedisSemanticCache @@ -62,6 +63,7 @@ class ValkeySemanticCache(RedisSemanticCache): sync_client: Redis | None = None, async_client: AsyncRedis | None = None, embedding_max_input_tokens: int | None = None, + embedding_timeout: float | None = None, **kwargs: Any, ): if similarity_threshold is None: @@ -80,6 +82,7 @@ class ValkeySemanticCache(RedisSemanticCache): self.similarity_threshold = similarity_threshold self.embedding_model = embedding_model self.embedding_max_input_tokens = embedding_max_input_tokens + self.embedding_timeout = resolve_embedding_timeout(embedding_timeout) self.index_name = index_name or self.DEFAULT_VALKEY_INDEX_NAME self.key_prefix = f"{self.index_name}:" self._index_dim: int | None = None diff --git a/litellm/constants.py b/litellm/constants.py index a845b1a49ae..762b7f1201c 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -423,6 +423,10 @@ DEFAULT_REQUEST_TIMEOUT_SECONDS: Final[float] = 6000.0 # deadline and connect handshake (see ``http_handler`` cached handler paths). COMPLETION_HTTP_FALLBACK_SECONDS: Final[float] = 600.0 HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS: Final[float] = 5.0 +# A cache lookup is an optimization, so it gets its own short deadline rather than the request timeout above. +SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS: Final[float] = float( + os.getenv("SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS", "5.0") +) request_timeout: float = float(os.getenv("REQUEST_TIMEOUT", str(int(DEFAULT_REQUEST_TIMEOUT_SECONDS)))) request_timeout_explicitly_set: bool = "REQUEST_TIMEOUT" in os.environ DEFAULT_A2A_AGENT_TIMEOUT: Final[float] = float(os.getenv("DEFAULT_A2A_AGENT_TIMEOUT", 6000)) # 10 minutes diff --git a/litellm/main.py b/litellm/main.py index 98c220f94e0..c3af24e1a51 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -5974,7 +5974,7 @@ def embedding( # Optional params dimensions: int | None = None, encoding_format: str | None = None, - timeout=600, # default to 10 minutes + timeout: float = 600, # default to 10 minutes # set api_base, api_version, api_key api_base: str | None = None, api_version: str | None = None, @@ -6000,7 +6000,7 @@ def embedding( # Optional params dimensions: int | None = None, encoding_format: str | None = None, - timeout=600, # default to 10 minutes + timeout: float = 600, # default to 10 minutes # set api_base, api_version, api_key api_base: str | None = None, api_version: str | None = None, @@ -6027,7 +6027,7 @@ def embedding( # Optional params dimensions: int | None = None, encoding_format: str | None = None, - timeout=600, # default to 10 minutes + timeout: float = 600, # default to 10 minutes # set api_base, api_version, api_key api_base: str | None = None, api_version: str | None = None, diff --git a/tests/test_litellm/caching/test_redis_semantic_cache.py b/tests/test_litellm/caching/test_redis_semantic_cache.py index 9fd333cf87c..54f1fa721a2 100644 --- a/tests/test_litellm/caching/test_redis_semantic_cache.py +++ b/tests/test_litellm/caching/test_redis_semantic_cache.py @@ -1329,3 +1329,157 @@ def test_redis_llmcache_setter_supported(): sentinel = MagicMock() cache.llmcache = sentinel assert cache.llmcache is sentinel + + +def _router_proxy_module(router, model_name): + import types + + fake_proxy = types.ModuleType("litellm.proxy.proxy_server") + fake_proxy.llm_router = router + fake_proxy.llm_model_list = [{"model_name": model_name}] + return fake_proxy + + +def test_redis_sync_embedding_call_is_bounded(monkeypatch): + import sys + + from litellm.caching.redis_semantic_cache import RedisSemanticCache + + cache = RedisSemanticCache.__new__(RedisSemanticCache) + cache.embedding_model = "sem-embed" + cache.embedding_timeout = 1.5 + + router = MagicMock() + router.get_configured_token_limits.return_value = (None, None) + router.embedding = MagicMock(return_value={"data": [{"embedding": [0.5, 0.6]}]}) + monkeypatch.setitem( + sys.modules, + "litellm.proxy.proxy_server", + _router_proxy_module(router, "sem-embed"), + ) + + assert cache._get_embedding("hello") == [0.5, 0.6] + assert router.embedding.call_args.kwargs["timeout"] == 1.5 + assert router.embedding.call_args.kwargs["num_retries"] == 0 + + +@pytest.mark.asyncio +async def test_redis_async_embedding_call_is_bounded(monkeypatch): + import sys + + from litellm.caching.redis_semantic_cache import RedisSemanticCache + + cache = RedisSemanticCache.__new__(RedisSemanticCache) + cache.embedding_model = "sem-embed" + cache.embedding_timeout = 1.5 + + router = MagicMock() + router.get_configured_token_limits.return_value = (None, None) + router.aembedding = AsyncMock(return_value={"data": [{"embedding": [0.5, 0.6]}]}) + monkeypatch.setitem( + sys.modules, + "litellm.proxy.proxy_server", + _router_proxy_module(router, "sem-embed"), + ) + + assert await cache._get_async_embedding("hello") == [0.5, 0.6] + assert router.aembedding.call_args.kwargs["timeout"] == 1.5 + assert router.aembedding.call_args.kwargs["num_retries"] == 0 + + +@pytest.mark.asyncio +async def test_redis_async_embedding_gives_up_on_unresponsive_endpoint(monkeypatch): + import asyncio + import sys + import time + + from litellm.caching.redis_semantic_cache import RedisSemanticCache + + cache = RedisSemanticCache.__new__(RedisSemanticCache) + cache.embedding_model = "sem-embed" + cache.embedding_timeout = 0.05 + + async def never_responds(**kwargs): + await asyncio.sleep(3) + return {"data": [{"embedding": [0.1, 0.2]}]} + + router = MagicMock() + router.get_configured_token_limits.return_value = (None, None) + router.aembedding = never_responds + monkeypatch.setitem( + sys.modules, + "litellm.proxy.proxy_server", + _router_proxy_module(router, "sem-embed"), + ) + + started = time.monotonic() + with pytest.raises(ValueError, match="Failed to generate embedding"): + await cache._get_async_embedding("hello") + assert time.monotonic() - started < 1.0 + + +@pytest.mark.asyncio +async def test_redis_async_get_cache_fails_open_when_embedding_hangs(monkeypatch): + import asyncio + import sys + import time + + from litellm.caching.redis_semantic_cache import RedisSemanticCache + + cache = RedisSemanticCache.__new__(RedisSemanticCache) + cache.embedding_model = "sem-embed" + cache.embedding_timeout = 0.05 + cache.similarity_threshold = 0.8 + cache.distance_threshold = 0.2 + cache.llmcache = MagicMock() + + async def never_responds(**kwargs): + await asyncio.sleep(3) + return {"data": [{"embedding": [0.1, 0.2]}]} + + router = MagicMock() + router.get_configured_token_limits.return_value = (None, None) + router.aembedding = never_responds + monkeypatch.setitem( + sys.modules, + "litellm.proxy.proxy_server", + _router_proxy_module(router, "sem-embed"), + ) + + metadata = {} + started = time.monotonic() + result = await cache.async_get_cache( + key="test_key", + messages=[{"role": "user", "content": "What is the capital of France?"}], + metadata=metadata, + ) + elapsed = time.monotonic() - started + + assert result is None + assert metadata["semantic-similarity"] == 0.0 + assert elapsed < 1.0 + cache.llmcache.acheck.assert_not_called() + + +def test_cache_forwards_semantic_cache_embedding_timeout(): + from litellm.caching.caching import Cache + from litellm.types.caching import LiteLLMCacheType + + with patch("litellm.caching.caching.RedisSemanticCache") as backend: + Cache( + type=LiteLLMCacheType.REDIS_SEMANTIC, + similarity_threshold=0.8, + redis_url="redis://localhost:6379", + semantic_cache_embedding_timeout=2.5, + ) + + assert backend.call_args.kwargs["embedding_timeout"] == 2.5 + + +def test_redis_semantic_cache_defaults_embedding_timeout(): + from litellm.caching.redis_semantic_cache import RedisSemanticCache + from litellm.constants import SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS + + cache = RedisSemanticCache.__new__(RedisSemanticCache) + assert cache.embedding_timeout == SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS + assert SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS < 60 From 70035251ada66e26832fa0e75324698d7b0c308e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:17:21 -0700 Subject: [PATCH 34/70] test: cover the qdrant semantic cache embedding deadline --- .../caching/test_qdrant_semantic_cache.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/test_litellm/caching/test_qdrant_semantic_cache.py b/tests/test_litellm/caching/test_qdrant_semantic_cache.py index 852bed4a9df..a5fbaf151ca 100644 --- a/tests/test_litellm/caching/test_qdrant_semantic_cache.py +++ b/tests/test_litellm/caching/test_qdrant_semantic_cache.py @@ -966,3 +966,67 @@ async def test_qdrant_async_embedding_explicit_limit_beats_deployment_limit(monk sent_input = router.aembedding.call_args.kwargs["input"] assert _token_count("sem-embed", sent_input) == 3 + + +@pytest.mark.asyncio +async def test_qdrant_async_embedding_call_is_bounded(monkeypatch): + from litellm.caching.qdrant_semantic_cache import QdrantSemanticCache + + cache = QdrantSemanticCache.__new__(QdrantSemanticCache) + cache.embedding_model = "sem-embed" + cache.embedding_max_input_tokens = None + cache.embedding_timeout = 1.5 + + router = MagicMock() + router.get_configured_token_limits.return_value = (None, None) + router.aembedding = AsyncMock(return_value={"data": [{"embedding": [0.1, 0.2]}]}) + monkeypatch.setitem( + sys.modules, + "litellm.proxy.proxy_server", + _router_proxy_module(router, "sem-embed"), + ) + + await cache._get_async_embedding("What is the capital of France?") + + assert router.aembedding.call_args.kwargs["timeout"] == 1.5 + assert router.aembedding.call_args.kwargs["num_retries"] == 0 + + +@pytest.mark.asyncio +async def test_qdrant_async_embedding_gives_up_on_unresponsive_endpoint(monkeypatch): + import asyncio + import time + + from litellm.caching.qdrant_semantic_cache import QdrantSemanticCache + + cache = QdrantSemanticCache.__new__(QdrantSemanticCache) + cache.embedding_model = "sem-embed" + cache.embedding_max_input_tokens = None + cache.embedding_timeout = 0.05 + + async def never_responds(**kwargs): + await asyncio.sleep(3) + return {"data": [{"embedding": [0.1, 0.2]}]} + + router = MagicMock() + router.get_configured_token_limits.return_value = (None, None) + router.aembedding = never_responds + monkeypatch.setitem( + sys.modules, + "litellm.proxy.proxy_server", + _router_proxy_module(router, "sem-embed"), + ) + + started = time.monotonic() + with pytest.raises(asyncio.TimeoutError): + await cache._get_async_embedding("What is the capital of France?") + assert time.monotonic() - started < 1.0 + + +def test_qdrant_semantic_cache_defaults_embedding_timeout(): + from litellm.caching.qdrant_semantic_cache import QdrantSemanticCache + from litellm.constants import SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS + + cache = QdrantSemanticCache.__new__(QdrantSemanticCache) + assert cache.embedding_timeout == SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS + assert SEMANTIC_CACHE_EMBEDDING_TIMEOUT_SECONDS < 60 From 5c9ed89301fd7bf04c233e63ca0014c637361a29 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:20:40 -0700 Subject: [PATCH 35/70] fix: mark daybreak-blue-latest and gpt-5.6-sol as supporting computer use OpenAI documents computer_use as a supported tool for Daybreak Blue and its default snapshot gpt-5.6-sol, but neither entry carried supports_computer_use. Sibling gpt-5.6-cyber and daybreak-red-latest already set it, so /model/info and the capability gates reported blue as unable to use computer tools. The gap came in with the source PR rather than the consolidation: #37029 sets the flag on cyber and red only. Pinned by a new metadata test covering the daybreak family and the blue alias agreeing with its snapshot. --- ...odel_prices_and_context_window_backup.json | 2 + model_prices_and_context_window.json | 2 + .../test_daybreak_model_metadata.py | 52 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 tests/test_litellm/test_daybreak_model_metadata.py diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 3d987463564..0ba60cee399 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -25564,6 +25564,7 @@ "supported_output_modalities": [ "text" ], + "supports_computer_use": true, "supports_function_calling": true, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, @@ -25809,6 +25810,7 @@ "supported_output_modalities": [ "text" ], + "supports_computer_use": true, "supports_function_calling": true, "supports_native_streaming": true, "supports_pdf_input": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 3d987463564..0ba60cee399 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -25564,6 +25564,7 @@ "supported_output_modalities": [ "text" ], + "supports_computer_use": true, "supports_function_calling": true, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, @@ -25809,6 +25810,7 @@ "supported_output_modalities": [ "text" ], + "supports_computer_use": true, "supports_function_calling": true, "supports_native_streaming": true, "supports_pdf_input": true, diff --git a/tests/test_litellm/test_daybreak_model_metadata.py b/tests/test_litellm/test_daybreak_model_metadata.py new file mode 100644 index 00000000000..d04cca3c077 --- /dev/null +++ b/tests/test_litellm/test_daybreak_model_metadata.py @@ -0,0 +1,52 @@ +import json +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parents[2] +MAIN_PATH = REPO_ROOT / "model_prices_and_context_window.json" +BACKUP_PATH = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json" + +DAYBREAK_MODELS = ( + "gpt-5.6-cyber", + "daybreak-red-latest", + "daybreak-blue-latest", +) +BLUE_ALIAS = "daybreak-blue-latest" +BLUE_SNAPSHOT = "gpt-5.6-sol" + + +def _load(path): + with open(path) as f: + return json.load(f) + + +@pytest.mark.parametrize("model", DAYBREAK_MODELS) +def test_daybreak_capability_contract(model): + info = _load(MAIN_PATH).get(model) + assert info is not None, f"{model} missing from model_prices_and_context_window.json" + + assert info["litellm_provider"] == "openai" + assert info["mode"] == "chat" + assert info["supported_endpoints"] == ["/v1/chat/completions", "/v1/responses"] + + assert info["supports_computer_use"] is True + assert info["supports_parallel_function_calling"] is True + assert info["supports_function_calling"] is True + assert info["supports_reasoning"] is True + assert info["supports_vision"] is True + + +def test_blue_alias_matches_its_snapshot_computer_use(): + cost_map = _load(MAIN_PATH) + + assert cost_map[BLUE_ALIAS]["supports_computer_use"] is True + assert cost_map[BLUE_SNAPSHOT]["supports_computer_use"] is True + + +@pytest.mark.parametrize("model", (*DAYBREAK_MODELS, BLUE_SNAPSHOT)) +def test_backup_matches_main(model): + main_cost = _load(MAIN_PATH) + backup_cost = _load(BACKUP_PATH) + + assert backup_cost.get(model) == main_cost.get(model), f"{model} differs between main and backup model cost maps" From 756a5fa62630e542e55e8690a0ac305d79fc786d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:32:46 -0700 Subject: [PATCH 36/70] test(responses_bridge): type the incomplete-response test helpers The three helpers added for the incomplete-response tests took untyped parameters, which the repo's typing rule does not allow. Annotate them through a TYPE_CHECKING block so the runtime imports stay inside the function bodies like the rest of this file. --- ...responses_transformation_transformation.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 6b0c82c9a46..315a8c6ed95 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -3,7 +3,7 @@ import json import os import sys import unittest -from typing import List, Optional, Tuple +from typing import TYPE_CHECKING, List, Optional, Tuple from unittest.mock import ANY, MagicMock, Mock, patch import httpx @@ -17,6 +17,13 @@ from litellm.completion_extras.litellm_responses_transformation.transformation i LiteLLMResponsesTransformationHandler, ) +if TYPE_CHECKING: + from openai.types.responses import ResponseOutputItem + from openai.types.responses.response_reasoning_item import ResponseReasoningItem + + from litellm.types.llms.openai import ResponsesAPIResponse + from litellm.types.utils import ModelResponse + def test_convert_chat_completion_messages_to_responses_api_image_input(): from litellm.completion_extras.litellm_responses_transformation.transformation import ( @@ -3487,7 +3494,10 @@ async def test_acompletion_bridge_normalizes_tool_choice_on_the_wire( assert request_body["tool_choice"] == expected_wire_tool_choice -def _make_incomplete_responses_api_response(incomplete_reason, output): +def _make_incomplete_responses_api_response( + incomplete_reason: Optional[str], + output: "List[ResponseOutputItem]", +) -> "ResponsesAPIResponse": from litellm.types.llms.openai import ( InputTokensDetails, OutputTokensDetails, @@ -3540,7 +3550,7 @@ def _make_incomplete_responses_api_response(incomplete_reason, output): ) -def _make_reasoning_only_output_item(): +def _make_reasoning_only_output_item() -> "ResponseReasoningItem": from openai.types.responses.response_reasoning_item import ResponseReasoningItem return ResponseReasoningItem( @@ -3553,7 +3563,10 @@ def _make_reasoning_only_output_item(): ) -def _call_transform_response(handler, raw_response): +def _call_transform_response( + handler: LiteLLMResponsesTransformationHandler, + raw_response: "ResponsesAPIResponse", +) -> "ModelResponse": logging_obj = Mock() logging_obj.model_call_details = {} return handler.transform_response( From c73480c65301986225aac553d638cd546f2dbfa1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:37:51 -0700 Subject: [PATCH 37/70] fix(proxy): block every unpriced model a request names A request can name more than one model, through a comma-separated model or target_model_names on the batch and fine-tuning routes, and the gate only looked at the string case, so an unpriced model riding alongside a priced one went through and billed. Check every candidate and name the unpriced ones in the 403 Aliases had the same problem on the other side: a group that prices itself through its model_info block lands in the cost map under its deployment id, and the explicit-cost check walked the raw model list by group name, so an alias pointing at that group read as unpriced. Resolve the group through the router the way the pricing check already does Also correct the 403 copy. Providers that return their own usage cost still bill for these models, so the accurate claim is that litellm has no pricing of its own for them --- litellm/proxy/auth/auth_checks.py | 55 +++++++++++++++---- .../proxy/auth/test_auth_checks.py | 55 +++++++++++++++++++ 2 files changed, 98 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 0bf419ca1de..7d3246aef0a 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -513,6 +513,24 @@ def _model_group_has_pricing(model: str, llm_router: "Router") -> bool: return False +def _group_declares_explicit_cost(model: str, llm_router: "Router") -> bool: + """ + Alias-aware counterpart to ``_is_cost_explicitly_configured``, which resolves the model group + the same way ``_model_group_has_pricing`` does. A deployment that prices itself through its + ``model_info`` block lands in the cost map under its deployment id rather than in its + litellm_params, and reaching that entry through the router's own resolution keeps an alias + pointing at such a group from being read as unpriced. + """ + for deployment in llm_router.get_model_list(model_name=model) or (): + model_id = (deployment.get("model_info") or _EMPTY_COST_ENTRY).get("id") + if model_id is None: + continue + raw_entry = litellm.model_cost.get(model_id, _EMPTY_COST_ENTRY) + if "input_cost_per_token" in raw_entry or "output_cost_per_token" in raw_entry: + return True + return False + + def model_has_no_cost_mapping(model: str | None, llm_router: Router | None) -> bool: if not model or llm_router is None: return False @@ -523,7 +541,24 @@ def model_has_no_cost_mapping(model: str | None, llm_router: Router | None) -> b if _model_group_has_pricing(model=model, llm_router=llm_router): return False - return not _is_cost_explicitly_configured(model, llm_router) + return not _group_declares_explicit_cost(model=model, llm_router=llm_router) + + +def _unpriced_models_in_request(model: str | list[str] | None, llm_router: Router | None) -> tuple[str, ...]: + candidates: Final = (model,) if isinstance(model, str) else tuple(model or ()) + return tuple( + candidate for candidate in candidates if model_has_no_cost_mapping(model=candidate, llm_router=llm_router) + ) + + +def _unpriced_models_block_message(models: tuple[str, ...]) -> str: + names: Final = ", ".join(f"'{model}'" for model in models) + subject: Final = f"Model {names} has" if len(models) == 1 else f"Models {names} have" + return ( + f"{subject} no pricing in the cost map, so litellm cannot price the request. " + "Requests for unpriced models are blocked because 'block_requests_for_models_without_pricing' " + "is enabled. Add pricing (input_cost_per_token/output_cost_per_token) to allow the request." + ) async def _run_project_checks( @@ -796,18 +831,14 @@ async def common_checks( and (route in MODEL_DISCOVERY_ROUTES or not RouteChecks.is_llm_api_route(route=route)) ) - if ( - litellm.block_requests_for_models_without_pricing - and isinstance(_model, str) - and RouteChecks.is_llm_api_route(route=route) - and model_has_no_cost_mapping(model=_model, llm_router=llm_router) - ): + unpriced_models: Final = ( + _unpriced_models_in_request(model=_model, llm_router=llm_router) + if litellm.block_requests_for_models_without_pricing and RouteChecks.is_llm_api_route(route=route) + else () + ) + if unpriced_models: raise ProxyException( - message=( - f"Model '{_model}' has no pricing in the cost map, so its spend would be tracked as $0. " - "Requests for unpriced models are blocked because 'block_requests_for_models_without_pricing' " - "is enabled. Add pricing for this model (input_cost_per_token/output_cost_per_token) to allow it." - ), + message=_unpriced_models_block_message(unpriced_models), type=ProxyErrorTypes.model_cost_map_missing, param="model", code=status.HTTP_403_FORBIDDEN, diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index b8b50cddb1e..840899220ea 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -6784,3 +6784,58 @@ async def test_common_checks_blocks_alias_resolving_to_unpriced_model(monkeypatc assert exc_info.value.code == "403" assert exc_info.value.type == ProxyErrorTypes.model_cost_map_missing assert "public-alias" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_common_checks_blocks_comma_separated_request_carrying_an_unpriced_model(monkeypatch): + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) + router = _router_with_priced_and_unpriced_models() + + with pytest.raises(ProxyException) as exc_info: + await _run_common_checks(model="priced-group,unpriced-group", llm_router=router) + + assert exc_info.value.code == "403" + assert exc_info.value.type == ProxyErrorTypes.model_cost_map_missing + assert "'unpriced-group'" in exc_info.value.message + assert "'priced-group'" not in exc_info.value.message + + +@pytest.mark.asyncio +async def test_common_checks_allows_comma_separated_request_when_every_model_is_priced(monkeypatch): + monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) + router = _router_with_priced_and_unpriced_models() + + result = await _run_common_checks(model="priced-group,priced-group", llm_router=router) + + assert result is True + + +def _router_with_a_group_priced_through_model_info() -> "Router": + from litellm.router import Router + + return Router( + model_list=[ + { + "model_name": "model-info-priced-group", + "litellm_params": {"model": f"{UNPRICED_UNDERLYING_MODEL}-model-info", "api_key": "sk-test"}, + "model_info": {"input_cost_per_token": 0, "output_cost_per_token": 0}, + } + ], + model_group_alias={"model-info-priced-alias": "model-info-priced-group"}, + ) + + +def test_model_has_no_cost_mapping_group_priced_through_model_info_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + + router = _router_with_a_group_priced_through_model_info() + + assert model_has_no_cost_mapping(model="model-info-priced-group", llm_router=router) is False + + +def test_model_has_no_cost_mapping_alias_to_a_group_priced_through_model_info_is_false(): + from litellm.proxy.auth.auth_checks import model_has_no_cost_mapping + + router = _router_with_a_group_priced_through_model_info() + + assert model_has_no_cost_mapping(model="model-info-priced-alias", llm_router=router) is False From e00301703f2f4274f77177c02bba04f02155a280 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:16:53 -0700 Subject: [PATCH 38/70] feat(cognition): give Cognition its own provider identity Cognition serves an OpenAI-compatible /v1/chat/completions endpoint, so it has been onboarded as custom_llm_provider: openai. That books its traffic as OpenAI, which means OpenAI-specific cost discounts and provider-level reporting apply to it. Registers cognition through the JSON provider registry: a providers.json entry with COGNITION_API_KEY and COGNITION_API_BASE, LlmProviders.COGNITION, the constants.py provider lists, cost map entries for swe-1.6 and swe-1.7, the provider endpoints matrix, the dashboard provider fields, and tests. JSON providers can now also be resolved from their base url alone, so an api_base pointing at a known provider no longer falls through to an unresolved provider. --- README.md | 1 + litellm/constants.py | 2 + .../get_llm_provider_logic.py | 3 + litellm/llms/openai_like/json_loader.py | 5 + litellm/llms/openai_like/providers.json | 5 + ...odel_prices_and_context_window_backup.json | 20 ++ .../provider_endpoints_support_backup.json | 17 ++ .../provider_create_fields.json | 28 +++ litellm/types/utils.py | 1 + model_prices_and_context_window.json | 20 ++ provider_endpoints_support.json | 17 ++ .../openai_like/test_cognition_provider.py | 173 ++++++++++++++++++ .../public_endpoints/test_public_endpoints.py | 31 ++++ .../components/provider_info_helpers.test.tsx | 5 + .../src/components/provider_info_helpers.tsx | 3 + 15 files changed, 331 insertions(+) create mode 100644 tests/test_litellm/llms/openai_like/test_cognition_provider.py diff --git a/README.md b/README.md index 32b0160dbaa..247e072f3a7 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,7 @@ curl -X POST 'http://0.0.0.0:4000/v1/chat/completions' \ | [Clarifai (`clarifai`)](https://docs.litellm.ai/docs/providers/clarifai) | ✅ | ✅ | ✅ | | | | | | | | | [Cloudflare AI Workers (`cloudflare`)](https://docs.litellm.ai/docs/providers/cloudflare_workers) | ✅ | ✅ | ✅ | | | | | | | | | [Codestral (`codestral`)](https://docs.litellm.ai/docs/providers/codestral) | ✅ | ✅ | ✅ | | | | | | | | +| [Cognition (`cognition`)](https://docs.litellm.ai/docs/providers/cognition) | ✅ | ✅ | | | | | | | | | | [Cohere (`cohere`)](https://docs.litellm.ai/docs/providers/cohere) | ✅ | ✅ | ✅ | ✅ | | | | | | ✅ | | [Cohere Chat (`cohere_chat`)](https://docs.litellm.ai/docs/providers/cohere) | ✅ | ✅ | ✅ | | | | | | | | | [CometAPI (`cometapi`)](https://docs.litellm.ai/docs/providers/cometapi) | ✅ | ✅ | ✅ | ✅ | | | | | | | diff --git a/litellm/constants.py b/litellm/constants.py index a845b1a49ae..ccbeb260a83 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -763,6 +763,7 @@ openai_compatible_endpoints: Final[list] = [ "https://api.libertai.io/v1", "https://pinstripes.io/v1", "https://api.meta.ai/v1", + "https://api.cognition.ai/v1", ] @@ -830,6 +831,7 @@ openai_compatible_providers: Final[list] = [ "pinstripes", # Pinstripes - JSON-configured provider "darkbloom", "meta", # Meta Model API (Muse Spark) - JSON-configured provider + "cognition", # Cognition - JSON-configured provider ] openai_text_completion_compatible_providers: Final[list] = [ # providers that support `/v1/completions` "together_ai", diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index dbb40913e14..e674fc37673 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -349,6 +349,9 @@ def get_llm_provider( elif endpoint == "https://api.meta.ai/v1": custom_llm_provider = "meta" dynamic_api_key = get_secret_str("META_API_KEY") + elif (json_provider := JSONProviderRegistry.get_by_base_url(endpoint)) is not None: + custom_llm_provider = json_provider.slug + dynamic_api_key = api_key if api_key is not None else get_secret_str(json_provider.api_key_env) if api_base is not None and not isinstance(api_base, str): raise Exception(f"api base needs to be a string. api_base={api_base}") diff --git a/litellm/llms/openai_like/json_loader.py b/litellm/llms/openai_like/json_loader.py index 38f3866cfc3..5cdaff90d24 100644 --- a/litellm/llms/openai_like/json_loader.py +++ b/litellm/llms/openai_like/json_loader.py @@ -65,6 +65,11 @@ class JSONProviderRegistry: """Check if a provider is defined via JSON""" return slug in cls._providers + @classmethod + def get_by_base_url(cls, base_url: str) -> SimpleProviderConfig | None: + """Get a provider configuration by its default base url""" + return next((provider for provider in cls._providers.values() if provider.base_url == base_url), None) + @classmethod def supports_responses_api(cls, slug: str) -> bool: """Check if a JSON provider supports the Responses API""" diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index 164100d4194..5f57aaa78d8 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -175,6 +175,11 @@ "base_class": "openai_gpt", "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"] }, + "cognition": { + "base_url": "https://api.cognition.ai/v1", + "api_key_env": "COGNITION_API_KEY", + "api_base_env": "COGNITION_API_BASE" + }, "pinstripes": { "base_url": "https://pinstripes.io/v1", "api_key_env": "PINSTRIPES_API_KEY", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b9c8824aa67..5d859a05963 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -48475,6 +48475,26 @@ "supports_reasoning": true, "supports_vision": false }, + "cognition/swe-1.6": { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 2.5e-06, + "cache_read_input_token_cost": 2e-07, + "litellm_provider": "cognition", + "mode": "chat", + "supports_function_calling": true, + "supports_prompt_caching": true, + "source": "https://docs.devin.ai/windsurf/plugins/cascade/models" + }, + "cognition/swe-1.7": { + "input_cost_per_token": 2.5e-06, + "output_cost_per_token": 1.25e-05, + "cache_read_input_token_cost": 1e-06, + "litellm_provider": "cognition", + "mode": "chat", + "supports_function_calling": true, + "supports_prompt_caching": true, + "source": "https://docs.devin.ai/windsurf/plugins/cascade/models" + }, "pinstripes/ps/glm-4.5-air": { "max_tokens": 128000, "max_input_tokens": 128000, diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index dd7712aabca..d47d74ead28 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -528,6 +528,23 @@ "interactions": true } }, + "cognition": { + "display_name": "Cognition (`cognition`)", + "url": "https://docs.litellm.ai/docs/providers/cognition", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false + } + }, "cohere": { "display_name": "Cohere (`cohere`)", "url": "https://docs.litellm.ai/docs/providers/cohere", diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index ab13773614a..9c2e94dd861 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -772,6 +772,34 @@ ], "default_model_placeholder": "gpt-3.5-turbo" }, + { + "provider": "Cognition", + "provider_display_name": "Cognition", + "litellm_provider": "cognition", + "credential_fields": [ + { + "key": "api_base", + "label": "API Base", + "placeholder": "https://api.cognition.ai/v1", + "tooltip": null, + "required": false, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "api_key", + "label": "API Key", + "placeholder": null, + "tooltip": null, + "required": true, + "field_type": "password", + "options": null, + "default_value": null + } + ], + "default_model_placeholder": "cognition/swe-1.7" + }, { "provider": "Cohere", "provider_display_name": "Cohere", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 41210d18495..e6138101899 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3782,6 +3782,7 @@ class LlmProviders(str, Enum): TENSORMESH = "tensormesh" LIBERTAI = "libertai" PINSTRIPES = "pinstripes" + COGNITION = "cognition" DARKBLOOM = "darkbloom" META = "meta" LITELLM_AGENT = "litellm_agent" diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b9c8824aa67..5d859a05963 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -48475,6 +48475,26 @@ "supports_reasoning": true, "supports_vision": false }, + "cognition/swe-1.6": { + "input_cost_per_token": 5e-07, + "output_cost_per_token": 2.5e-06, + "cache_read_input_token_cost": 2e-07, + "litellm_provider": "cognition", + "mode": "chat", + "supports_function_calling": true, + "supports_prompt_caching": true, + "source": "https://docs.devin.ai/windsurf/plugins/cascade/models" + }, + "cognition/swe-1.7": { + "input_cost_per_token": 2.5e-06, + "output_cost_per_token": 1.25e-05, + "cache_read_input_token_cost": 1e-06, + "litellm_provider": "cognition", + "mode": "chat", + "supports_function_calling": true, + "supports_prompt_caching": true, + "source": "https://docs.devin.ai/windsurf/plugins/cascade/models" + }, "pinstripes/ps/glm-4.5-air": { "max_tokens": 128000, "max_input_tokens": 128000, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index ec0b1c27344..950bf61dbb7 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -563,6 +563,23 @@ "interactions": true } }, + "cognition": { + "display_name": "Cognition (`cognition`)", + "url": "https://docs.litellm.ai/docs/providers/cognition", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false + } + }, "cohere": { "display_name": "Cohere (`cohere`)", "url": "https://docs.litellm.ai/docs/providers/cohere", diff --git a/tests/test_litellm/llms/openai_like/test_cognition_provider.py b/tests/test_litellm/llms/openai_like/test_cognition_provider.py new file mode 100644 index 00000000000..26bdfa82944 --- /dev/null +++ b/tests/test_litellm/llms/openai_like/test_cognition_provider.py @@ -0,0 +1,173 @@ +""" +Tests for the Cognition provider identity. + +Cognition serves an OpenAI-compatible /v1/chat/completions surface, but it must resolve to its +own `cognition` provider so OpenAI-specific pricing and provider-level reporting never apply to +its traffic. +""" + +import json +from pathlib import Path + +import pytest + +import litellm + + +class TestCognitionProviderIdentity: + def test_cognition_is_a_registered_provider(self): + from litellm import LlmProviders + + assert LlmProviders.COGNITION.value == "cognition" + assert "cognition" in litellm.provider_list + + def test_cognition_json_config(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + cognition = JSONProviderRegistry.get("cognition") + assert cognition is not None + assert cognition.base_url == "https://api.cognition.ai/v1" + assert cognition.api_key_env == "COGNITION_API_KEY" + assert cognition.api_base_env == "COGNITION_API_BASE" + + def test_cognition_in_openai_compatible_providers(self): + from litellm.constants import openai_compatible_providers + + assert "cognition" in openai_compatible_providers + + def test_prefixed_model_resolves_to_cognition_not_openai(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, _, api_base = get_llm_provider( + model="cognition/swe-1.7", + custom_llm_provider=None, + api_base=None, + api_key=None, + ) + + assert model == "swe-1.7" + assert provider == "cognition" + assert api_base == "https://api.cognition.ai/v1" + + def test_explicit_api_base_and_key_win(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + _, provider, api_key, api_base = get_llm_provider( + model="cognition/swe-1.7", + custom_llm_provider=None, + api_base="https://cognition.internal.example/v1", + api_key="sk-test", + ) + + assert provider == "cognition" + assert api_base == "https://cognition.internal.example/v1" + assert api_key == "sk-test" + + def test_api_base_autodetects_cognition(self, monkeypatch: pytest.MonkeyPatch): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + monkeypatch.setenv("COGNITION_API_KEY", "sk-cognition-env") + + _, provider, api_key, api_base = get_llm_provider( + model="swe-1.7", + custom_llm_provider=None, + api_base="https://api.cognition.ai/v1", + api_key=None, + ) + + assert provider == "cognition" + assert api_base == "https://api.cognition.ai/v1" + assert api_key == "sk-cognition-env" + + def test_autodetected_api_base_keeps_the_caller_api_key(self, monkeypatch: pytest.MonkeyPatch): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + monkeypatch.setenv("COGNITION_API_KEY", "sk-cognition-env") + + _, provider, api_key, _ = get_llm_provider( + model="swe-1.7", + custom_llm_provider=None, + api_base="https://api.cognition.ai/v1", + api_key="sk-cognition-caller", + ) + + assert provider == "cognition" + assert api_key == "sk-cognition-caller" + + def test_env_api_key_is_read_from_cognition_variable(self, monkeypatch: pytest.MonkeyPatch): + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv("COGNITION_API_KEY", "sk-cognition-env") + + provider = JSONProviderRegistry.get("cognition") + assert provider is not None + + api_base, api_key = create_config_class(provider)()._get_openai_compatible_provider_info(None, None) + assert api_base == "https://api.cognition.ai/v1" + assert api_key == "sk-cognition-env" + + +class TestCognitionCostTracking: + @pytest.mark.parametrize( + "model, input_cost, output_cost", + [ + ("cognition/swe-1.6", 5e-07, 2.5e-06), + ("cognition/swe-1.7", 2.5e-06, 1.25e-05), + ], + ) + def test_cost_map_entries(self, model: str, input_cost: float, output_cost: float): + info = litellm.get_model_info(model=model) + + assert info["litellm_provider"] == "cognition" + assert info["mode"] == "chat" + assert info["input_cost_per_token"] == input_cost + assert info["output_cost_per_token"] == output_cost + + def test_cost_differs_from_openai_pricing(self): + """A cognition-prefixed model must never be priced off an OpenAI cost entry.""" + from litellm.cost_calculator import cost_per_token + + prompt_cost, completion_cost = cost_per_token( + model="cognition/swe-1.7", + prompt_tokens=1_000_000, + completion_tokens=1_000_000, + custom_llm_provider="cognition", + ) + + assert prompt_cost == pytest.approx(2.5) + assert completion_cost == pytest.approx(12.5) + + def test_supported_endpoints_matrix(self): + matrix = json.loads((Path(litellm.__file__).parent / "provider_endpoints_support_backup.json").read_text()) + + endpoints = matrix["providers"]["cognition"]["endpoints"] + assert endpoints["chat_completions"] is True + assert endpoints["embeddings"] is False + + +class TestCognitionRouting: + @pytest.mark.asyncio + async def test_router_spend_is_attributed_to_cognition_pricing(self): + """Routed traffic is costed off the cognition entry, not an OpenAI one.""" + from litellm import Router + + router = Router( + model_list=[ + { + "model_name": "swe", + "litellm_params": {"model": "cognition/swe-1.7", "api_key": "sk-test"}, + } + ] + ) + + response = await router.acompletion( + model="swe", + messages=[{"role": "user", "content": "hi"}], + mock_response="hello from swe", + ) + + usage = response.usage + expected = usage.prompt_tokens * 2.5e-06 + usage.completion_tokens * 1.25e-05 + assert response._hidden_params["response_cost"] == pytest.approx(expected) diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index 03d228cc732..72e7b1c18d6 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -298,6 +298,37 @@ def test_nvidia_riva_provider_fields(): assert fields_by_key["nvcf_function_id"]["required"] is False +def test_cognition_provider_fields(): + """Cognition must be selectable in the Add Model flow (LIT-5348). + + The dropdown is driven entirely by /public/providers/fields, so without an + entry here admins have to fall back to the generic OpenAI-compatible route, + which is exactly the provider identity mix-up this feature removes. + """ + app_instance = FastAPI() + app_instance.include_router(router) + test_client = TestClient(app_instance) + + response = test_client.get("/public/providers/fields") + assert response.status_code == 200 + providers = response.json() + + cognition = next((p for p in providers if p["provider"] == "Cognition"), None) + assert cognition is not None, "Cognition provider entry not found" + + assert cognition["provider_display_name"] == "Cognition" + assert cognition["litellm_provider"] == "cognition" + assert cognition["default_model_placeholder"].startswith("cognition/") + + fields_by_key = {f["key"]: f for f in cognition["credential_fields"]} + + assert fields_by_key["api_key"]["required"] is True + assert fields_by_key["api_key"]["field_type"] == "password" + + assert fields_by_key["api_base"]["field_type"] == "text" + assert fields_by_key["api_base"]["required"] is False + + def test_google_ai_studio_provider_fields_expose_api_base(): """The Google AI Studio (gemini) credential form must let admins set a custom api_base so they can point at a Gemini-compatible gateway (e.g. a self-hosted diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index e9bb63dd964..f2dbd866c2d 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -137,6 +137,7 @@ describe("provider_info_helpers", () => { Providers.AUTO_ROUTER, Providers.BYTEZ, Providers.CLARIFAI, + Providers.Cognition, Providers.COMPACTIFAI, Providers.DATAROBOT, Providers.DOCKER_MODEL_RUNNER, @@ -252,6 +253,10 @@ describe("provider_info_helpers", () => { expect(getPlaceholder("WATSONX")).toBe("watsonx/ibm/granite-3-3-8b-instruct"); }); + it("should return cognition/swe-1.7 placeholder for Cognition provider", () => { + expect(getPlaceholder(Providers.Cognition)).toBe("cognition/swe-1.7"); + }); + it("should return default gpt-3.5-turbo placeholder for unknown provider", () => { expect(getPlaceholder("UnknownProvider" as any)).toBe("gpt-3.5-turbo"); }); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index ad6d044eb7b..519438622f3 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -85,6 +85,7 @@ export enum Providers { CLARIFAI = "Clarifai", CLOUDFLARE = "Cloudflare", CODESTRAL = "Codestral", + Cognition = "Cognition", Cohere = "Cohere", COHERE_CHAT = "Cohere Chat", COMETAPI = "Cometapi", @@ -194,6 +195,7 @@ export const provider_map: Record = { CLARIFAI: "clarifai", CLOUDFLARE: "cloudflare", CODESTRAL: "codestral", + Cognition: "cognition", Cohere: "cohere", COHERE_CHAT: "cohere_chat", COMETAPI: "cometapi", @@ -409,6 +411,7 @@ const providerPlaceholderMap: Partial> = { [Providers.Azure]: "my-deployment", [Providers.Azure_AI_Studio]: "azure_ai/command-r-plus", [Providers.Bedrock]: "claude-3-opus", + [Providers.Cognition]: "cognition/swe-1.7", [Providers.Cursor]: "cursor/claude-4-sonnet", [Providers.DeepInfra]: "deepinfra/", [Providers.FalAI]: "fal_ai/fal-ai/flux-pro/v1.1-ultra", From 7b25ee13c9fbf18a083e2e7e52483aee9a931b59 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:17:48 -0700 Subject: [PATCH 39/70] fold the two reasoning-item casts into one shared helper --- .../transformation.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 7a95ab6ac28..c6d5b04d370 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -139,6 +139,16 @@ def _reasoning_items_from_output_items(output_items: Sequence[object]) -> tuple[ ) +def _as_chat_reasoning_items( + reasoning_items: Sequence[_BuiltReasoningItem], +) -> list[ChatCompletionReasoningItem] | None: + if not reasoning_items: + return None + # cast-ok: _BuiltReasoningItem is the structural shape ChatCompletionReasoningItem + # describes, and TypedDict invariance is what stops the two from unifying here. + return cast(list[ChatCompletionReasoningItem], list(reasoning_items)) + + def _map_incomplete_reason_to_finish_reason(incomplete_reason: str | None) -> Literal["length", "content_filter"]: if incomplete_reason == "content_filter": return "content_filter" @@ -716,10 +726,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): message: Final = Message( content="", reasoning_content=reasoning_content if reasoning_content else None, - reasoning_items=cast( - list[ChatCompletionReasoningItem] | None, - reasoning_items or None, - ), + reasoning_items=_as_chat_reasoning_items(reasoning_items), ) return Choices(message=message, finish_reason=finish_reason, index=0) @@ -1485,10 +1492,8 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): else ("tool_calls" if has_function_calls else "stop") ) - terminal_reasoning_items: Final = _reasoning_items_from_output_items(output_items) - terminal_reasoning_items_typed: Final = cast( - list[ChatCompletionReasoningItem] | None, - list(terminal_reasoning_items) if terminal_reasoning_items else None, + terminal_reasoning_items_typed: Final = _as_chat_reasoning_items( + _reasoning_items_from_output_items(output_items) ) usage = None From 66a89f5a6ef5da00acccd5e74cecc96c6d4c65ae Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 20 Aug 2026 17:29:07 -0700 Subject: [PATCH 40/70] perf(reset_budget_job): elect one sweeper per tick and bound the window scan (#36497) Every pod schedules the budget reset job, so a fleet re-read the whole due population and wrote it back against one Postgres at the same calendar boundary, multiplying a single sweep by its replica count. The job now takes the shared PodLockManager lease, so one pod sweeps per tick. A deployment with no Redis keeps its previous behavior, and a Redis that cannot answer sweeps unguarded rather than stranding every expired budget at its cap. The per-window scan read every row carrying budget_limits in one statement, so its cost grew with the deployment's key count. It is now keyset-paginated and walks to the end of the table on every sweep. A per-run cap would need a resume position, and no pod can hold one because the lease rotates between ticks, so the strictly advancing cursor is what terminates the walk. Found and updated rows were also JSON-serialized into the service hook's metadata and into debug lines on every chunk, on the event loop, whether or not anything consumed them. The hooks now carry counts, and the debug payload is deferred until a record is actually emitted. Resolves LIT-4793 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 5 + .../proxy/common_utils/reset_budget_job.py | 328 +++++++++++----- litellm/proxy/proxy_server.py | 1 + .../test_proxy_budget_reset.py | 15 +- .../common_utils/test_reset_budget_job.py | 356 +++++++++++++++++- 5 files changed, 595 insertions(+), 110 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 774df63de17..ebda5a4d244 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1537,6 +1537,11 @@ DEFAULT_CRON_JOB_LOCK_TTL_SECONDS: Final = int(os.getenv("DEFAULT_CRON_JOB_LOCK_ PROXY_BUDGET_RESCHEDULER_MIN_TIME: Final = int(os.getenv("PROXY_BUDGET_RESCHEDULER_MIN_TIME", 597)) RESET_BUDGET_JOB_BATCH_SIZE: Final = max(1, int(os.getenv("RESET_BUDGET_JOB_BATCH_SIZE", "500"))) RESET_BUDGET_JOB_MAX_CHUNKS_PER_RUN: Final = max(1, int(os.getenv("RESET_BUDGET_JOB_MAX_CHUNKS_PER_RUN", "100"))) +RESET_BUDGET_JOB_NAME: Final = "reset_budget_job" +# Comfortably longer than one PROXY_BUDGET_RESCHEDULER_MIN_TIME tick, so a healthy +# leader keeps the lease across its own run, and a crashed one strands the sweep for +# at most a single tick. +RESET_BUDGET_JOB_LOCK_TTL_SECONDS: Final[int] = 900 PROXY_BATCH_POLLING_INTERVAL: Final = int(os.getenv("PROXY_BATCH_POLLING_INTERVAL", 3600)) MAX_OBJECTS_PER_POLL_CYCLE: Final = max(1, int(os.getenv("MAX_OBJECTS_PER_POLL_CYCLE", 50))) MANAGED_OBJECT_STALENESS_CUTOFF_DAYS: Final = max(1, int(os.getenv("MANAGED_OBJECT_STALENESS_CUTOFF_DAYS", 7))) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index b28b7291a4c..8fcb184b26a 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -4,6 +4,7 @@ import time from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime, timezone +from enum import Enum from types import MappingProxyType from typing import Final, Literal, Protocol, TypeVar, assert_never @@ -14,7 +15,9 @@ from litellm.constants import ( GLOBAL_PROXY_SPEND_CACHE_KEY, LITELLM_PROXY_BUDGET_NAME, RESET_BUDGET_JOB_BATCH_SIZE, + RESET_BUDGET_JOB_LOCK_TTL_SECONDS, RESET_BUDGET_JOB_MAX_CHUNKS_PER_RUN, + RESET_BUDGET_JOB_NAME, ) from litellm.proxy._types import ( DB_RETRY_SAFE_ERROR_TYPES, @@ -30,6 +33,7 @@ from litellm.proxy.common_utils.timezone_utils import ( get_budget_reset_settings, ) from litellm.proxy.common_utils.user_api_key_cache import tag_cache_key +from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager from litellm.proxy.db.exception_handler import call_with_db_reconnect_retry from litellm.proxy.utils import PrismaClient, ProxyLogging from litellm.repositories.organization_repository import OrganizationRepository @@ -195,12 +199,94 @@ async def _run_phase_in_chunks(process_chunk: Callable[[], Awaitable[_ChunkOutco return +@dataclass(frozen=True, slots=True) +class _LazyJson: + """Serialize only if a log record is actually emitted. + + ``logger.debug("... %s", json.dumps(rows))`` evaluates the dump before the + logger decides to drop the record, so a chunk of rows is serialized on the + event loop on every tick at any log level. Passing this instead defers the + work to the formatter. + """ + + value: object + + def __str__(self) -> str: + return json.dumps(self.value, indent=4, default=str) + + +class _Lease(Enum): + """Whether this pod may sweep, and whether it owes a lock release.""" + + LEADER = "leader" + UNGUARDED = "unguarded" + FOLLOWER = "follower" + + +async def _write_key_windows(prisma_client: PrismaClient, row_id: str, payload: str) -> None: + await VerificationTokenRepository(prisma_client).table.update( + where={"token": row_id}, + data={"budget_limits": payload}, + ) + + +async def _write_team_windows(prisma_client: PrismaClient, row_id: str, payload: str) -> None: + await TeamRepository(prisma_client).table.update( + where={"team_id": row_id}, + data={"budget_limits": payload}, + ) + + +@dataclass(frozen=True, slots=True) +class _WindowSource: + """A table whose rows carry their own per-window budget limits.""" + + table: str + id_column: str + counter_prefix: str + log_subject: str + retry_subject: str + write: Callable[[PrismaClient, str, str], Awaitable[None]] + + def page_query(self) -> str: + """One keyset page, ordered by the primary key so the cursor never repeats a row. + + prisma-client-python cannot null-filter a ``Json?`` column (no DbNull / + JsonNull sentinel, RobertCraigie/prisma-client-py#714), so the read stays + raw SQL; the table and column names are module constants, never input. + Writes still go through the ORM. + """ + return ( + f'SELECT {self.id_column}, budget_limits FROM "{self.table}" ' + f"WHERE budget_limits IS NOT NULL AND {self.id_column} > $1 " + f"ORDER BY {self.id_column} LIMIT $2" + ) + + +_WINDOW_SOURCES: Final[tuple[_WindowSource, ...]] = ( + _WindowSource( + table="LiteLLM_VerificationToken", + id_column="token", + counter_prefix="spend:key", + log_subject="keys", + retry_subject="key", + write=_write_key_windows, + ), + _WindowSource( + table="LiteLLM_TeamTable", + id_column="team_id", + counter_prefix="spend:team", + log_subject="teams", + retry_subject="team", + write=_write_team_windows, + ), +) + + def _budget_cascade_event_metadata(cascade: _BudgetCascade) -> dict[str, object]: return { "num_budgets_found": len(cascade.budgets), - "budgets_found": json.dumps(cascade.budgets, indent=4, default=str), "num_endusers_found": len(cascade.endusers), - "endusers_found": json.dumps(cascade.endusers, indent=4, default=str), } @@ -214,10 +300,61 @@ class ResetBudgetJob: proxy_logging_obj: ProxyLogging, prisma_client: PrismaClient, reset_settings: BudgetResetSettings | None = None, + pod_lock_manager: PodLockManager | None = None, ): self.proxy_logging_obj: ProxyLogging = proxy_logging_obj self.prisma_client: PrismaClient = prisma_client self.reset_settings: BudgetResetSettings = reset_settings or get_budget_reset_settings() + self.pod_lock_manager: PodLockManager | None = pod_lock_manager + + async def _lease_is_held(self, lock_manager: PodLockManager) -> bool: + """True only when the lease is readable and someone holds it. + + An unreadable lock reports as unheld so the caller sweeps rather than + skipping; being wrong here costs a duplicate sweep, and the alternative + strands every expired budget at its cap. + """ + if lock_manager.redis_cache is None: + return False + try: + lock_key: Final = lock_manager.get_redis_lock_key(RESET_BUDGET_JOB_NAME) + return bool(await lock_manager.redis_cache.async_get_cache(lock_key)) + except Exception as exc: # noqa: BLE001 # an unreadable lease must not strand the sweep + verbose_proxy_logger.warning("Reset budget job: could not read the reset lease: %s", exc) + return False + + async def _acquire_lease(self) -> _Lease: + """Elect one sweeper per tick. + + Every pod schedules this job, and each one otherwise re-reads the whole + due population and writes it back at the same calendar boundary, so a + fleet multiplies one sweep's Postgres load by its replica count. A + deployment with no Redis-backed lock manager runs unguarded, as it + always has. + """ + lock_manager: Final = self.pod_lock_manager + if lock_manager is None or lock_manager.redis_cache is None: + return _Lease.UNGUARDED + + if await lock_manager.acquire_lock( + cronjob_id=RESET_BUDGET_JOB_NAME, + ttl=RESET_BUDGET_JOB_LOCK_TTL_SECONDS, + ): + return _Lease.LEADER + + if await self._lease_is_held(lock_manager): + verbose_proxy_logger.debug("Reset budget job: another pod holds the reset lease, skipping this tick") + return _Lease.FOLLOWER + + # acquire_lock reports contention and an unreachable Redis identically, so + # treating a failed acquire as contention would skip the sweep on every pod + # at once for as long as Redis is down. Sweeping unguarded costs duplicate + # work; not sweeping leaves every expired budget pinned at its cap. + verbose_proxy_logger.warning( + "Reset budget job: could not take the reset lease and no other pod holds it, " + "sweeping unguarded rather than skipping the tick" + ) + return _Lease.UNGUARDED async def reset_budget( self, @@ -228,15 +365,25 @@ class ResetBudgetJob: Resets their spend Updates db + + Runs on one pod per tick where a Redis lease is available. """ if self.prisma_client is None: return - await self.reset_budget_for_litellm_keys() - await self.reset_budget_for_litellm_users() - await self.reset_budget_for_litellm_teams() - await self.reset_budget_for_litellm_budget_table() - await self.reset_budget_windows() + lease: Final = await self._acquire_lease() + if lease is _Lease.FOLLOWER: + return + + try: + await self.reset_budget_for_litellm_keys() + await self.reset_budget_for_litellm_users() + await self.reset_budget_for_litellm_teams() + await self.reset_budget_for_litellm_budget_table() + await self.reset_budget_windows() + finally: + if lease is _Lease.LEADER and self.pod_lock_manager is not None: + await self.pod_lock_manager.release_lock(cronjob_id=RESET_BUDGET_JOB_NAME) async def _with_db_retry(self, operation: Callable[[], Awaitable[_RowT]], *, reason: str) -> _RowT: """Reconnect and retry once on a transport error, so a dropped connection @@ -647,7 +794,7 @@ class ResetBudgetJob: ), reason="reset_budget_read_keys_failure", ) - verbose_proxy_logger.debug("Keys to reset %s", json.dumps(keys_to_reset, indent=4, default=str)) + verbose_proxy_logger.debug("Keys to reset %s", _LazyJson(keys_to_reset)) updated_keys: Final[list[LiteLLM_VerificationToken]] = [] failed_keys: Final = [] if keys_to_reset is not None and len(keys_to_reset) > 0: @@ -666,7 +813,7 @@ class ResetBudgetJob: failed_keys.append({"key": key, "error": str(e)}) verbose_proxy_logger.exception("Failed to reset budget for key: %s", key) - verbose_proxy_logger.debug("Updated keys %s", json.dumps(updated_keys, indent=4, default=str)) + verbose_proxy_logger.debug("Updated keys %s", _LazyJson(updated_keys)) if updated_keys: await self._write_key_reset_updates(updated_keys=updated_keys) @@ -691,7 +838,6 @@ class ResetBudgetJob: end_time=end_time, event_metadata={ "num_keys_found": len(keys_to_reset) if keys_to_reset else 0, - "keys_found": json.dumps(keys_to_reset, indent=4, default=str), }, ) return outcome @@ -705,11 +851,8 @@ class ResetBudgetJob: end_time=end_time, event_metadata={ "num_keys_found": len(keys_to_reset) if keys_to_reset else 0, - "keys_found": json.dumps(keys_to_reset, indent=4, default=str), "num_keys_updated": len(updated_keys), - "keys_updated": json.dumps(updated_keys, indent=4, default=str), "num_keys_failed": len(failed_keys), - "keys_failed": json.dumps(failed_keys, indent=4, default=str), }, ) ) @@ -725,7 +868,6 @@ class ResetBudgetJob: end_time=end_time, event_metadata={ "num_keys_found": len(keys_to_reset) if keys_to_reset else 0, - "keys_found": json.dumps(keys_to_reset, indent=4, default=str), }, ) ) @@ -777,7 +919,7 @@ class ResetBudgetJob: failed_users.append({"user": user, "error": str(e)}) verbose_proxy_logger.exception("Failed to reset budget for user: %s", user) - verbose_proxy_logger.debug("Updated users %s", json.dumps(updated_users, indent=4, default=str)) + verbose_proxy_logger.debug("Updated users %s", _LazyJson(updated_users)) if updated_users: await self._write_user_reset_updates(updated_users=updated_users) for u in updated_users: @@ -805,7 +947,6 @@ class ResetBudgetJob: end_time=end_time, event_metadata={ "num_users_found": len(users_to_reset) if users_to_reset else 0, - "users_found": json.dumps(users_to_reset, indent=4, default=str), }, ) return outcome @@ -819,11 +960,8 @@ class ResetBudgetJob: end_time=end_time, event_metadata={ "num_users_found": len(users_to_reset) if users_to_reset else 0, - "users_found": json.dumps(users_to_reset, indent=4, default=str), "num_users_updated": len(updated_users), - "users_updated": json.dumps(updated_users, indent=4, default=str), "num_users_failed": len(failed_users), - "users_failed": json.dumps(failed_users, indent=4, default=str), }, ) ) @@ -839,7 +977,6 @@ class ResetBudgetJob: end_time=end_time, event_metadata={ "num_users_found": len(users_to_reset) if users_to_reset else 0, - "users_found": json.dumps(users_to_reset, indent=4, default=str), }, ) ) @@ -891,7 +1028,7 @@ class ResetBudgetJob: failed_teams.append({"team": team, "error": str(e)}) verbose_proxy_logger.exception("Failed to reset budget for team: %s", team) - verbose_proxy_logger.debug("Updated teams %s", json.dumps(updated_teams, indent=4, default=str)) + verbose_proxy_logger.debug("Updated teams %s", _LazyJson(updated_teams)) if updated_teams: await self._write_team_reset_updates(updated_teams=updated_teams) for t in updated_teams: @@ -917,7 +1054,6 @@ class ResetBudgetJob: end_time=end_time, event_metadata={ "num_teams_found": len(teams_to_reset) if teams_to_reset else 0, - "teams_found": json.dumps(teams_to_reset, indent=4, default=str), }, ) return outcome @@ -931,11 +1067,8 @@ class ResetBudgetJob: end_time=end_time, event_metadata={ "num_teams_found": len(teams_to_reset) if teams_to_reset else 0, - "teams_found": json.dumps(teams_to_reset, indent=4, default=str), "num_teams_updated": len(updated_teams), - "teams_updated": json.dumps(updated_teams, indent=4, default=str), "num_teams_failed": len(failed_teams), - "teams_failed": json.dumps(failed_teams, indent=4, default=str), }, ) ) @@ -951,7 +1084,6 @@ class ResetBudgetJob: end_time=end_time, event_metadata={ "num_teams_found": len(teams_to_reset) if teams_to_reset else 0, - "teams_found": json.dumps(teams_to_reset, indent=4, default=str), }, ) ) @@ -995,82 +1127,82 @@ class ResetBudgetJob: from litellm.proxy.proxy_server import spend_counter_cache now: Final = datetime.utcnow() + for source in _WINDOW_SOURCES: + try: + await self._reset_windows_for(source=source, now=now, spend_counter_cache=spend_counter_cache) + except Exception as e: + verbose_proxy_logger.exception("Failed to reset budget windows for %s: %s", source.log_subject, e) - # Note on raw SQL: prisma-client-python does not support null-filtering - # on `Json?` columns (no DbNull/JsonNull sentinel — see - # RobertCraigie/prisma-client-py#714). We use `query_raw` with - # `IS NOT NULL` so we don't materialize every key/team row on each - # tick of the reset job. Writes still go through the ORM. + async def _reset_windows_for( + self, + source: _WindowSource, + now: datetime, + spend_counter_cache: DualCache, + ) -> None: + """Walk one table's windowed rows a page at a time, to the end. - # --- Keys --- - try: - key_rows: Final = await self._with_db_retry( - lambda: self.prisma_client.db.query_raw( - 'SELECT token, budget_limits FROM "LiteLLM_VerificationToken" WHERE budget_limits IS NOT NULL' - ), - reason="reset_budget_read_key_windows_failure", + Paging is what bounds the memory: the previous form pulled every row + carrying budget_limits into one result set on every tick, which grows + with the deployment's key count and is paid on the event loop. + + The walk deliberately has no per-run page cap. A cap has to remember + where it stopped, and that position cannot live in the process: the + lease is released after each sweep, so the next tick can elect a + different pod whose own position is unset. It would restart at the first + row and never reach the tail, pinning those windows at their cap for + good. The cursor strictly advances, so the walk terminates on its own + without needing a bound. + """ + cursor = "" + while True: + next_cursor = await self._reset_window_page( + source=source, + cursor=cursor, + now=now, + spend_counter_cache=spend_counter_cache, ) - for row in key_rows: - raw = row["budget_limits"] - if not raw: - continue - windows: list = raw if isinstance(raw, list) else json.loads(raw) - changed = False - for window in windows: - counter_key = f"spend:key:{row['token']}:window:{window['budget_duration']}" - if await ResetBudgetJob._reset_expired_window( - window, - counter_key, - spend_counter_cache, - now, - self.reset_settings, - ): - changed = True - if changed: - await self._with_db_write_retry( - lambda: VerificationTokenRepository(self.prisma_client).table.update( - where={"token": row["token"]}, - data={"budget_limits": json.dumps(windows)}, - ), - reason="reset_budget_write_key_windows_failure", - ) - except Exception as e: - verbose_proxy_logger.exception("Failed to reset budget windows for keys: %s", e) + if next_cursor is None: + return + cursor = next_cursor - # --- Teams --- - try: - team_rows: Final = await self._with_db_retry( - lambda: self.prisma_client.db.query_raw( - 'SELECT team_id, budget_limits FROM "LiteLLM_TeamTable" WHERE budget_limits IS NOT NULL' - ), - reason="reset_budget_read_team_windows_failure", - ) - for row in team_rows: - raw = row["budget_limits"] - if not raw: - continue - windows = raw if isinstance(raw, list) else json.loads(raw) - changed = False - for window in windows: - counter_key = f"spend:team:{row['team_id']}:window:{window['budget_duration']}" - if await ResetBudgetJob._reset_expired_window( - window, - counter_key, - spend_counter_cache, - now, - self.reset_settings, - ): - changed = True - if changed: - await self._with_db_write_retry( - lambda: TeamRepository(self.prisma_client).table.update( - where={"team_id": row["team_id"]}, - data={"budget_limits": json.dumps(windows)}, - ), - reason="reset_budget_write_team_windows_failure", - ) - except Exception as e: - verbose_proxy_logger.exception("Failed to reset budget windows for teams: %s", e) + async def _reset_window_page( + self, + source: _WindowSource, + cursor: str, + now: datetime, + spend_counter_cache: DualCache, + ) -> str | None: + """Reset one page of windows; return the next cursor, or None when drained.""" + rows: Final = await self._with_db_retry( + lambda: self.prisma_client.db.query_raw(source.page_query(), cursor, RESET_BUDGET_JOB_BATCH_SIZE), + reason=f"reset_budget_read_{source.retry_subject}_windows_failure", + ) + for row in rows: + raw = row["budget_limits"] + if not raw: + continue + row_id: str = row[source.id_column] + windows: list = raw if isinstance(raw, list) else json.loads(raw) + changed = False + for window in windows: + counter_key = f"{source.counter_prefix}:{row_id}:window:{window['budget_duration']}" + if await ResetBudgetJob._reset_expired_window( + window, + counter_key, + spend_counter_cache, + now, + self.reset_settings, + ): + changed = True + if changed: + await self._with_db_write_retry( + lambda: source.write(self.prisma_client, row_id, json.dumps(windows)), + reason=f"reset_budget_write_{source.retry_subject}_windows_failure", + ) + + if len(rows) < RESET_BUDGET_JOB_BATCH_SIZE: + return None + return rows[-1][source.id_column] @staticmethod async def _reset_budget_common( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4b97cade7f0..448810a5931 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -8875,6 +8875,7 @@ class ProxyStartupEvent: proxy_logging_obj=proxy_logging_obj, prisma_client=prisma_client, reset_settings=get_budget_reset_settings(), + pod_lock_manager=proxy_logging_obj.db_spend_update_writer.pod_lock_manager, ) scheduler.add_job( diff --git a/tests/litellm_utils_tests/test_proxy_budget_reset.py b/tests/litellm_utils_tests/test_proxy_budget_reset.py index b13b7342c25..a188fcf9d72 100644 --- a/tests/litellm_utils_tests/test_proxy_budget_reset.py +++ b/tests/litellm_utils_tests/test_proxy_budget_reset.py @@ -748,8 +748,9 @@ async def test_service_logger_keys_failure(): ) = proxy_logging_obj.service_logging_obj.async_service_failure_hook.call_args event_metadata = kwargs.get("event_metadata", {}) assert event_metadata.get("num_keys_found") == len(keys) - keys_found_str = event_metadata.get("keys_found", "") - assert "key1" in keys_found_str + # the row payload is deliberately absent: serializing every found row on the + # event loop is what blocked auth on the sweeping pod + assert "keys_found" not in event_metadata # Success hook should not be called. proxy_logging_obj.service_logging_obj.async_service_success_hook.assert_not_called() @@ -866,8 +867,7 @@ async def test_service_logger_users_failure(): ) = proxy_logging_obj.service_logging_obj.async_service_failure_hook.call_args event_metadata = kwargs.get("event_metadata", {}) assert event_metadata.get("num_users_found") == len(users) - users_found_str = event_metadata.get("users_found", "") - assert "user1" in users_found_str + assert "users_found" not in event_metadata proxy_logging_obj.service_logging_obj.async_service_success_hook.assert_not_called() @@ -983,8 +983,7 @@ async def test_service_logger_teams_failure(): ) = proxy_logging_obj.service_logging_obj.async_service_failure_hook.call_args event_metadata = kwargs.get("event_metadata", {}) assert event_metadata.get("num_teams_found") == len(teams) - teams_found_str = event_metadata.get("teams_found", "") - assert "team1" in teams_found_str + assert "teams_found" not in event_metadata proxy_logging_obj.service_logging_obj.async_service_success_hook.assert_not_called() @@ -1113,8 +1112,8 @@ async def test_service_logger_endusers_failure(): event_metadata = kwargs.get("event_metadata", {}) assert event_metadata.get("num_budgets_found") == len(budgets) assert event_metadata.get("num_endusers_found") == len(endusers) - endusers_found_str = event_metadata.get("endusers_found", "") - assert "user1" in endusers_found_str + assert "endusers_found" not in event_metadata + assert "budgets_found" not in event_metadata proxy_logging_obj.service_logging_obj.async_service_success_hook.assert_not_called() diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py index c5bc4e29f81..8233b0d3864 100644 --- a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py +++ b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py @@ -16,6 +16,11 @@ sys.path.insert(0, os.path.abspath("../../..")) # Adds the parent directory to from litellm.proxy._types import LiteLLM_VerificationToken from litellm.proxy.common_utils import reset_budget_job as reset_budget_job_module +from litellm.constants import ( + PROXY_BUDGET_RESCHEDULER_MIN_TIME, + RESET_BUDGET_JOB_LOCK_TTL_SECONDS, + RESET_BUDGET_JOB_NAME, +) from litellm.proxy.common_utils.reset_budget_job import ResetBudgetJob from litellm.proxy.common_utils.timezone_utils import BudgetResetSettings @@ -1906,10 +1911,7 @@ def test_key_reset_keeps_paging_when_some_rows_in_a_chunk_fail(monkeypatch): assert client.fetches_by_table["key"] == 2 assert [w["where"]["token"] for w in _batch_writes(client, "key", op="update")] == ["k1", "k2"] assert [call["call_type"] for call in logging_obj.service_logging_obj.failure_calls] == ["reset_budget_keys"] - assert set(logging_obj.service_logging_obj.failure_calls[0]["event_metadata"]) == { - "num_keys_found", - "keys_found", - } + assert set(logging_obj.service_logging_obj.failure_calls[0]["event_metadata"]) == {"num_keys_found"} assert [call["call_type"] for call in logging_obj.service_logging_obj.success_calls] == ["reset_budget_keys"] @@ -1936,6 +1938,352 @@ def test_user_and_team_chunks_report_progress_despite_a_failed_row( assert [call["call_type"] for call in logging_obj.service_logging_obj.failure_calls] == [call_type] +class FakePodLockManager: + """Stands in for the redis-backed PodLockManager. + + Lets a test pick which of the three states a pod lands in: it wins the + lease, another pod already holds it, or redis cannot answer at all. + """ + + def __init__(self, *, acquired: bool, held_by_other: bool = False, has_redis: bool = True): + self.redis_cache = MagicMock() if has_redis else None + if self.redis_cache is not None: + self.redis_cache.async_get_cache = AsyncMock(return_value="another-pod" if held_by_other else None) + self._acquired = acquired + self.acquire_calls: List[Dict[str, Any]] = [] + self.release_calls: List[str] = [] + + @staticmethod + def get_redis_lock_key(cronjob_id: str) -> str: + return f"cronjob_lock:{cronjob_id}" + + async def acquire_lock(self, cronjob_id: str, ttl: Any = None) -> bool: + self.acquire_calls.append({"cronjob_id": cronjob_id, "ttl": ttl}) + return self._acquired + + async def release_lock(self, cronjob_id: str) -> None: + self.release_calls.append(cronjob_id) + + +def _make_leader_election_job(monkeypatch, pod_lock_manager): + """A ResetBudgetJob wired to one lock manager, with every read observable. + + `prisma_client.get_data_calls` plus `prisma_client.db.query_raw` together + cover every read the sweep makes, so a pod that skipped the tick leaves + both untouched. + """ + prisma_client = MockPrismaClient() + prisma_client.db.query_raw = AsyncMock(return_value=[]) + + spend_counter_cache = MagicMock() + spend_counter_cache.redis_cache = None + fake_module = types.ModuleType("litellm.proxy.proxy_server") + fake_module.spend_counter_cache = spend_counter_cache + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module) + + job = ResetBudgetJob( + proxy_logging_obj=MockProxyLogging(), + prisma_client=prisma_client, + pod_lock_manager=pod_lock_manager, + ) + return job, prisma_client + + +def _swept(prisma_client) -> bool: + return bool(prisma_client.get_data_calls) or prisma_client.db.query_raw.await_count > 0 + + +def test_reset_budget_sweeps_and_releases_when_it_wins_the_lease(monkeypatch): + """The elected pod does the work and hands the lease back, so the next tick + can elect any pod rather than waiting out the TTL.""" + lock = FakePodLockManager(acquired=True) + job, prisma_client = _make_leader_election_job(monkeypatch, lock) + + asyncio.run(job.reset_budget()) + + assert _swept(prisma_client) + assert [call["cronjob_id"] for call in lock.acquire_calls] == [RESET_BUDGET_JOB_NAME] + assert lock.release_calls == [RESET_BUDGET_JOB_NAME] + + +def test_reset_budget_does_nothing_when_another_pod_holds_the_lease(monkeypatch): + """The whole point of the lease: a fleet must not multiply one sweep by its + replica count. A pod that loses the election issues no query at all, and + must not release a lease it never took.""" + lock = FakePodLockManager(acquired=False, held_by_other=True) + job, prisma_client = _make_leader_election_job(monkeypatch, lock) + + asyncio.run(job.reset_budget()) + + assert not _swept(prisma_client) + assert lock.release_calls == [] + + +def test_reset_budget_sweeps_unguarded_when_redis_cannot_answer(monkeypatch): + """acquire_lock reports contention and an unreachable redis identically, so + reading a failed acquire as contention would strand every expired budget at + its cap on every pod for as long as redis is down. No holder means sweep.""" + lock = FakePodLockManager(acquired=False, held_by_other=False) + job, prisma_client = _make_leader_election_job(monkeypatch, lock) + + asyncio.run(job.reset_budget()) + + assert _swept(prisma_client) + assert lock.release_calls == [] + + +def test_reset_budget_sweeps_when_the_deployment_has_no_redis(monkeypatch): + """A single-pod or redis-less deployment keeps its pre-election behavior.""" + lock = FakePodLockManager(acquired=False, has_redis=False) + job, prisma_client = _make_leader_election_job(monkeypatch, lock) + + asyncio.run(job.reset_budget()) + + assert _swept(prisma_client) + assert lock.acquire_calls == [] + assert lock.release_calls == [] + + +def test_reset_budget_sweeps_when_no_lock_manager_is_injected(monkeypatch): + """Callers that construct the job without a lock manager still sweep.""" + job, prisma_client = _make_leader_election_job(monkeypatch, None) + + asyncio.run(job.reset_budget()) + + assert _swept(prisma_client) + + +def test_reset_budget_releases_the_lease_when_a_phase_raises(monkeypatch): + """A crash mid-sweep must not hold the lease for its whole TTL, which would + stop every pod resetting budgets until it expired.""" + lock = FakePodLockManager(acquired=True) + job, _ = _make_leader_election_job(monkeypatch, lock) + + async def boom() -> None: + raise RuntimeError("phase exploded") + + monkeypatch.setattr(job, "reset_budget_for_litellm_keys", boom) + + with pytest.raises(RuntimeError): + asyncio.run(job.reset_budget()) + + assert lock.release_calls == [RESET_BUDGET_JOB_NAME] + + +def test_reset_budget_lease_outlives_one_scheduler_tick(monkeypatch): + """A lease shorter than the gap between ticks expires mid-sweep and lets a + second pod start sweeping, which is the amplification the lease removes.""" + lock = FakePodLockManager(acquired=True) + job, _ = _make_leader_election_job(monkeypatch, lock) + + asyncio.run(job.reset_budget()) + + assert lock.acquire_calls[0]["ttl"] == RESET_BUDGET_JOB_LOCK_TTL_SECONDS + assert RESET_BUDGET_JOB_LOCK_TTL_SECONDS > PROXY_BUDGET_RESCHEDULER_MIN_TIME + + +def _window_row(source_id_column: str, row_id: str, reset_at: datetime) -> Dict[str, Any]: + return { + source_id_column: row_id, + "budget_limits": [{"budget_duration": "1h", "reset_at": reset_at.isoformat(), "max_budget": 10}], + } + + +def _paginating_window_job(monkeypatch, pages_by_table: Dict[str, List[List[Dict[str, Any]]]]): + """Serve each table a canned sequence of pages and record every query. + + Returns (job, calls) where calls is a list of (sql, cursor, limit). + """ + prisma_client = MagicMock() + remaining = {table: list(pages) for table, pages in pages_by_table.items()} + calls: List[Dict[str, Any]] = [] + + async def fake_query_raw(query: str, *args, **kwargs): + table = "key" if '"LiteLLM_VerificationToken"' in query else "team" + calls.append({"table": table, "sql": query, "cursor": args[0], "limit": args[1]}) + pages = remaining[table] + return pages.pop(0) if pages else [] + + prisma_client.db.query_raw = AsyncMock(side_effect=fake_query_raw) + prisma_client.db.litellm_verificationtoken.update = AsyncMock(return_value=None) + prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=None) + + spend_counter_cache = MagicMock() + spend_counter_cache.redis_cache = None + fake_module = types.ModuleType("litellm.proxy.proxy_server") + fake_module.spend_counter_cache = spend_counter_cache + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module) + + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + return job, calls + + +def test_reset_budget_windows_pages_by_cursor_instead_of_reading_the_table(monkeypatch): + """The window scan used to read every row carrying budget_limits in one + statement, so its memory and its statement cost grew with the deployment's + key count. It now walks pages, and each page resumes past the last row. + """ + monkeypatch.setattr(reset_budget_job_module, "RESET_BUDGET_JOB_BATCH_SIZE", 2) + past = datetime.utcnow() - timedelta(hours=2) + job, calls = _paginating_window_job( + monkeypatch, + { + "key": [ + [_window_row("token", "k1", past), _window_row("token", "k2", past)], + [_window_row("token", "k3", past)], + ], + "team": [[]], + }, + ) + + asyncio.run(job.reset_budget_windows()) + + key_calls = [call for call in calls if call["table"] == "key"] + assert [call["cursor"] for call in key_calls] == ["", "k2"], "second page must resume past the last row read" + assert {call["limit"] for call in key_calls} == {2} + assert all("LIMIT $2" in call["sql"] for call in key_calls) + # the short second page ends the scan; a third query would re-read forever + assert len(key_calls) == 2 + + +def test_reset_budget_windows_pages_to_the_end_of_a_large_table(monkeypatch): + """The scan must reach the last row within one tick. + + Capping the pages per run would need a resume position, and that position + cannot live in the process: the lease is released after every sweep, so a + later tick can elect a pod whose position is unset, restart at the first + row, and leave the tail pinned at its cap forever. Paging alone bounds the + memory, so the walk runs to completion instead. + """ + monkeypatch.setattr(reset_budget_job_module, "RESET_BUDGET_JOB_BATCH_SIZE", 1) + # the table needs far more pages than any per-run cap would allow, so a + # capped walk stops short and only an uncapped one reaches the last row + monkeypatch.setattr(reset_budget_job_module, "RESET_BUDGET_JOB_MAX_CHUNKS_PER_RUN", 3) + past = datetime.utcnow() - timedelta(hours=2) + rows = [_window_row("token", f"k{i:03d}", past) for i in range(1, 26)] + job, visited = _cursor_paginating_window_job(monkeypatch, rows) + + asyncio.run(job.reset_budget_windows()) + + assert visited == [f"k{i:03d}" for i in range(1, 26)], visited + + +def test_reset_budget_windows_survives_one_table_failing(monkeypatch): + """A broken key scan must not cost the team scan its sweep.""" + prisma_client = MagicMock() + + async def fake_query_raw(query: str, *args, **kwargs): + if '"LiteLLM_VerificationToken"' in query: + raise RuntimeError("key scan exploded") + return [] + + prisma_client.db.query_raw = AsyncMock(side_effect=fake_query_raw) + spend_counter_cache = MagicMock() + spend_counter_cache.redis_cache = None + fake_module = types.ModuleType("litellm.proxy.proxy_server") + fake_module.spend_counter_cache = spend_counter_cache + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module) + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + + asyncio.run(job.reset_budget_windows()) + + queried = [call.args[0] for call in prisma_client.db.query_raw.await_args_list] + assert any('"LiteLLM_TeamTable"' in sql for sql in queried) + + +def test_row_payloads_stay_out_of_reset_job_event_metadata(monkeypatch): + """Every found and updated row used to be JSON-serialized into the service + hook's metadata on every chunk, on the event loop, whether or not any + consumer read it. Only the counts are reported now.""" + client = ChunkedPrismaClient({"key": [[_key_row("k1"), _key_row("k2")]]}) + logging_obj = RecordingProxyLogging() + job = ResetBudgetJob(proxy_logging_obj=logging_obj, prisma_client=client) + + _run_and_drain_hooks(job.reset_budget_for_litellm_keys) + + metadata = logging_obj.service_logging_obj.success_calls[0]["event_metadata"] + assert metadata["num_keys_found"] == 2 + assert metadata["num_keys_updated"] == 2 + assert {"keys_found", "keys_updated", "keys_failed"}.isdisjoint(metadata) + assert all(isinstance(value, int) for value in metadata.values()), metadata + + +def test_debug_row_dump_is_deferred_until_a_record_is_emitted(): + """`logger.debug("%s", json.dumps(rows))` serializes before the logger drops + the record, so the sweep paid for a full dump of every chunk at any log + level. The wrapper defers the work to the formatter.""" + serialized = [] + + class Tracked: + def __repr__(self) -> str: + serialized.append("serialized") + return "tracked" + + lazy = reset_budget_job_module._LazyJson([Tracked()]) + assert serialized == [], "constructing the wrapper must not serialize" + + assert "tracked" in str(lazy) + assert serialized == ["serialized"] + + +def _cursor_paginating_window_job(monkeypatch, key_rows: List[Dict[str, Any]]): + """Serve real keyset pages out of one ordered table, honouring the cursor. + + Unlike the canned-page helper above, this models the database: a page is + whatever rows sort after the cursor, so a scan that forgets its cursor + genuinely re-reads the same prefix. + """ + prisma_client = MagicMock() + ordered = sorted(key_rows, key=lambda r: r["token"]) + visited: List[str] = [] + + async def fake_query_raw(query: str, *args, **kwargs): + if '"LiteLLM_TeamTable"' in query: + return [] + cursor, limit = args[0], args[1] + page = [row for row in ordered if row["token"] > cursor][:limit] + visited.extend(row["token"] for row in page) + return page + + prisma_client.db.query_raw = AsyncMock(side_effect=fake_query_raw) + prisma_client.db.litellm_verificationtoken.update = AsyncMock(return_value=None) + prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=None) + + spend_counter_cache = MagicMock() + spend_counter_cache.redis_cache = None + fake_module = types.ModuleType("litellm.proxy.proxy_server") + fake_module.spend_counter_cache = spend_counter_cache + monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", fake_module) + + job = ResetBudgetJob(proxy_logging_obj=MagicMock(), prisma_client=prisma_client) + return job, visited + + +def test_every_tick_sweeps_the_whole_window_table_whichever_pod_won(monkeypatch): + """Coverage must not depend on which pod was elected. + + The lease is released after each sweep, so consecutive ticks routinely run + on different pods. A scan carrying a resume position in process memory would + have a fresh pod start over at the first row, so rows past one run's reach + would never be swept by anyone. Two independent job instances, standing in + for two pods, must each cover the table end to end. + """ + monkeypatch.setattr(reset_budget_job_module, "RESET_BUDGET_JOB_BATCH_SIZE", 2) + monkeypatch.setattr(reset_budget_job_module, "RESET_BUDGET_JOB_MAX_CHUNKS_PER_RUN", 2) + past = datetime.utcnow() - timedelta(hours=2) + rows = [_window_row("token", f"k{i:03d}", past) for i in range(1, 12)] + expected = [f"k{i:03d}" for i in range(1, 12)] + + pod_a, visited_a = _cursor_paginating_window_job(monkeypatch, rows) + pod_b, visited_b = _cursor_paginating_window_job(monkeypatch, rows) + + asyncio.run(pod_a.reset_budget_windows()) + asyncio.run(pod_b.reset_budget_windows()) + + assert visited_a == expected, visited_a + assert visited_b == expected, visited_b + + class FlakyPrismaClient(MockPrismaClient): """A client whose first N reads (or first N batch commits) fail with a transport error, and which records every reconnect attempt. From 94239d281fa5795dbc8859e076387904593d9bf7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:33:33 -0700 Subject: [PATCH 41/70] test(e2e): name the pinned GitHub issue in each passthrough test docstring The passthrough tests and their coverage registry rows pointed at the internal ticket id, which does not resolve for anyone following a link from status.litellm.ai. Each test docstring and registry rationale now names the GitHub issue it pins: #36086 for the two prefix routing cases, #36087 for the file list cursors, #36523 for streamed Responses cost, and #36646 for embeddings spend. --- tests/e2e/batches/test_batches_e2e.py | 3 ++- tests/e2e/coverage_registry/llm_conversational.yaml | 2 +- tests/e2e/coverage_registry/llm_nonconversational.yaml | 8 ++++---- tests/e2e/llm_translation/test_passthrough_e2e.py | 9 +++++++++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/e2e/batches/test_batches_e2e.py b/tests/e2e/batches/test_batches_e2e.py index 536bc113a25..12b848dd063 100644 --- a/tests/e2e/batches/test_batches_e2e.py +++ b/tests/e2e/batches/test_batches_e2e.py @@ -579,7 +579,8 @@ class TestOpenAIFiles: def test_list_page_cursors_address_only_the_callers_own_files( self, client: BatchClient, resources: ResourceManager ) -> None: - """A list page's pagination cursors must address rows in that page. + """Pins GitHub issue #36087: a list page's pagination cursors must address + rows in that page. The proxy fronts one shared provider account, so the upstream page is the whole organization's. The gateway narrows `data` to the files the caller diff --git a/tests/e2e/coverage_registry/llm_conversational.yaml b/tests/e2e/coverage_registry/llm_conversational.yaml index 1ddc146c12d..acd3d602033 100644 --- a/tests/e2e/coverage_registry/llm_conversational.yaml +++ b/tests/e2e/coverage_registry/llm_conversational.yaml @@ -63,7 +63,7 @@ - {id: llm.responses.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: responses, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.9 / LIT-4778", rationale: "Responses missing/empty input and missing model are rejected"} - {id: llm.responses.openai.basic.stream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Streaming via /v1/responses"} - {id: llm.responses.openai.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: nonstream, assertions: [works, cost_logged], source: "response_api_endpoints/endpoints.py:26", rationale: "Cost logged on responses"} -- {id: llm.responses.openai.passthrough.stream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [cost_logged], source: "test_passthrough_e2e.py", rationale: "A streamed POST /openai_passthrough/v1/responses is costed and keyed by the provider response id; it used to log a zero-cost row under a random id (LIT-5870)"} +- {id: llm.responses.openai.passthrough.stream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [cost_logged], source: "test_passthrough_e2e.py", rationale: "A streamed POST /openai_passthrough/v1/responses is costed and keyed by the provider response id; it used to log a zero-cost row under a random id (GitHub issue #36523)"} - {id: llm.responses.openai.tool_use.nonstream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: tool_use, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Tool calls via Responses API"} - {id: llm.responses.openai.vision.nonstream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: vision, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Vision via Responses API"} - {id: llm.responses.anthropic.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: responses, route: anthropic, capability: basic, streaming: nonstream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Responses w/ Anthropic translation (smoke)"} diff --git a/tests/e2e/coverage_registry/llm_nonconversational.yaml b/tests/e2e/coverage_registry/llm_nonconversational.yaml index 674d369b49f..8ae7dd01b5a 100644 --- a/tests/e2e/coverage_registry/llm_nonconversational.yaml +++ b/tests/e2e/coverage_registry/llm_nonconversational.yaml @@ -3,7 +3,7 @@ - {id: llm.embeddings.openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_embeddings_endpoint_e2e.py:23", rationale: "Core endpoint, live vector response"} - {id: llm.embeddings.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.3 / LIT-4778", rationale: "Missing model/input on /embeddings return client errors"} - {id: llm.embeddings.openai.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "SPEND_TRACKING_COVERAGE_MATRIX.md:34", rationale: "Cost tracking on embeddings"} -- {id: llm.embeddings.openai.passthrough.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "test_passthrough_e2e.py", rationale: "POST /openai_passthrough/v1/embeddings is costed; the route wrote no spend row at all, so budgets never saw traffic OpenAI was billing for (LIT-5870)"} +- {id: llm.embeddings.openai.passthrough.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: embeddings, route: openai, capability: basic, streaming: nonstream, assertions: [cost_logged], source: "test_passthrough_e2e.py", rationale: "POST /openai_passthrough/v1/embeddings is costed; the route wrote no spend row at all, so budgets never saw traffic OpenAI was billing for (GitHub issue #36646)"} - {id: llm.embeddings.azure_openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: embeddings, route: azure_openai, capability: basic, streaming: nonstream, assertions: [works], source: "llms/azure/azure.py", rationale: "Azure embeddings via translation"} - {id: llm.embeddings.bedrock.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "llms/bedrock/embed/embedding.py", rationale: "Bedrock Titan embeddings"} - {id: llm.embeddings.vertex.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: embeddings, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "vertex_embeddings/embedding_handler.py", rationale: "Vertex embeddings"} @@ -14,7 +14,7 @@ - {id: llm.batches.openai.cancel.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Batch cancel"} - {id: llm.batches.openai.list.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Batch list envelope"} - {id: llm.batches.openai.file_lifecycle.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "File upload/retrieve/delete for batch flow"} -- {id: llm.batches.openai.passthrough.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_passthrough_e2e.py", rationale: "GET /openai_passthrough/v1/batches relays OpenAI's own batch page; the dedicated prefix must not bind as a provider name on the /{provider}/v1/batches route (LIT-5870)"} +- {id: llm.batches.openai.passthrough.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_passthrough_e2e.py", rationale: "GET /openai_passthrough/v1/batches relays OpenAI's own batch page; the dedicated prefix must not bind as a provider name on the /{provider}/v1/batches route (GitHub issue #36086)"} - {id: llm.batches.openai_encoded.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py", rationale: "Encoded scenario lifecycle"} - {id: llm.batches.openai_unified.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py", rationale: "Unified/managed-id scenario"} - {id: llm.batches.openai_model_param.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py", rationale: "Model-param scenario"} @@ -31,8 +31,8 @@ - {id: llm.files.openai.retrieve.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "files_endpoints.py", rationale: "File retrieve by id"} - {id: llm.files.openai.delete.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "files_endpoints.py", rationale: "File delete returns deleted=true"} - {id: llm.files.openai.list.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "files_endpoints.py", rationale: "File list paginated"} -- {id: llm.files.openai.list_isolation.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "GET /v1/files pagination cursors address only rows the caller owns; on a shared provider account the upstream cursors otherwise hand out other tenants' raw provider file ids (LIT-5870)"} -- {id: llm.files.openai.passthrough.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_passthrough_e2e.py", rationale: "POST/DELETE /openai_passthrough/v1/files relay OpenAI's own file object; the dedicated prefix must not bind as a provider name on the /{provider}/v1/files route (LIT-5870)"} +- {id: llm.files.openai.list_isolation.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "GET /v1/files pagination cursors address only rows the caller owns; on a shared provider account the upstream cursors otherwise hand out other tenants' raw provider file ids (GitHub issue #36087)"} +- {id: llm.files.openai.passthrough.nonstream.works, module: llm, tier: P1, subject_endpoint: files, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_passthrough_e2e.py", rationale: "POST/DELETE /openai_passthrough/v1/files relay OpenAI's own file object; the dedicated prefix must not bind as a provider name on the /{provider}/v1/files route (GitHub issue #36086)"} - {id: llm.files.azure_openai.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: azure_openai, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:45", rationale: "Azure file upload managed backend"} - {id: llm.files.vertex.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:52", rationale: "Vertex file upload to GCS"} - {id: llm.files.bedrock.upload.nonstream.works, module: llm, tier: P0, subject_endpoint: files, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:59", rationale: "Bedrock file upload to S3"} diff --git a/tests/e2e/llm_translation/test_passthrough_e2e.py b/tests/e2e/llm_translation/test_passthrough_e2e.py index b084c711a88..17b0dbe1ae5 100644 --- a/tests/e2e/llm_translation/test_passthrough_e2e.py +++ b/tests/e2e/llm_translation/test_passthrough_e2e.py @@ -230,6 +230,8 @@ class TestOpenAIPassthroughPrefix: def test_passthrough_prefix_uploads_a_file_to_openai( self, client: PassthroughClient, resources: ResourceManager, scoped_key: str ) -> None: + """Pins GitHub issue #36086: a file upload through the dedicated prefix + reaches OpenAI's file API instead of 500ing on a provider-name lookup.""" content = f'{{"marker":"{unique_marker()}"}}\n'.encode() uploaded = unwrap( client.openai_passthrough_upload_file( @@ -250,6 +252,8 @@ class TestOpenAIPassthroughPrefix: def test_passthrough_prefix_lists_batches_from_openai( self, client: PassthroughClient, scoped_key: str ) -> None: + """Pins GitHub issue #36086 on the batches route: the dedicated prefix + relays OpenAI's own batch page instead of dying on the provider lookup.""" listed = unwrap(client.openai_passthrough_list_batches(scoped_key)) assert listed.object == "list", ( @@ -270,6 +274,9 @@ class TestOpenAIPassthroughSpend: def test_streamed_responses_call_logs_its_cost( self, client: PassthroughClient, scoped_key: str ) -> None: + """Pins GitHub issue #36523: a streamed passthrough Responses call is billed + under the provider id the caller was served, never a $0 row under a random + id.""" result = client.openai_passthrough_responses( scoped_key, CHEAP_OPENAI_MODEL, @@ -311,6 +318,8 @@ class TestOpenAIPassthroughSpend: def test_embeddings_call_logs_its_cost( self, client: PassthroughClient, scoped_key: str ) -> None: + """Pins GitHub issue #36646: a passthrough embeddings call writes a priced + spend row instead of no row at all.""" result = client.openai_passthrough_embed( scoped_key, EMBEDDING_MODEL, f"cost this sentence {unique_marker()}" ) From 332a0f1b9b1111a30301130b43411573a7fd971f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:33:59 -0700 Subject: [PATCH 42/70] fix(cognition): price swe-1.7 from the published standard tier The swe-1.7 rates were carried over from the closed prior attempt and match SWE-1.7 Lightning, 5x the SWE-1.7 Max and Medium rates the vendor publishes. swe-1.6 was already on the standard tier, so the two entries disagreed with each other. Both now read 0.5 in, 2.5 out, 0.2 cached per million tokens. Also drops the redundant registry comment in constants.py. --- litellm/constants.py | 2 +- litellm/model_prices_and_context_window_backup.json | 6 +++--- model_prices_and_context_window.json | 6 +++--- .../llms/openai_like/test_cognition_provider.py | 8 ++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index ccbeb260a83..c25e1b9eb31 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -831,7 +831,7 @@ openai_compatible_providers: Final[list] = [ "pinstripes", # Pinstripes - JSON-configured provider "darkbloom", "meta", # Meta Model API (Muse Spark) - JSON-configured provider - "cognition", # Cognition - JSON-configured provider + "cognition", ] openai_text_completion_compatible_providers: Final[list] = [ # providers that support `/v1/completions` "together_ai", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 5d859a05963..d63a888ae9c 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -48486,9 +48486,9 @@ "source": "https://docs.devin.ai/windsurf/plugins/cascade/models" }, "cognition/swe-1.7": { - "input_cost_per_token": 2.5e-06, - "output_cost_per_token": 1.25e-05, - "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 2.5e-06, + "cache_read_input_token_cost": 2e-07, "litellm_provider": "cognition", "mode": "chat", "supports_function_calling": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 5d859a05963..d63a888ae9c 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -48486,9 +48486,9 @@ "source": "https://docs.devin.ai/windsurf/plugins/cascade/models" }, "cognition/swe-1.7": { - "input_cost_per_token": 2.5e-06, - "output_cost_per_token": 1.25e-05, - "cache_read_input_token_cost": 1e-06, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 2.5e-06, + "cache_read_input_token_cost": 2e-07, "litellm_provider": "cognition", "mode": "chat", "supports_function_calling": true, diff --git a/tests/test_litellm/llms/openai_like/test_cognition_provider.py b/tests/test_litellm/llms/openai_like/test_cognition_provider.py index 26bdfa82944..6dcc02387cc 100644 --- a/tests/test_litellm/llms/openai_like/test_cognition_provider.py +++ b/tests/test_litellm/llms/openai_like/test_cognition_provider.py @@ -114,7 +114,7 @@ class TestCognitionCostTracking: "model, input_cost, output_cost", [ ("cognition/swe-1.6", 5e-07, 2.5e-06), - ("cognition/swe-1.7", 2.5e-06, 1.25e-05), + ("cognition/swe-1.7", 5e-07, 2.5e-06), ], ) def test_cost_map_entries(self, model: str, input_cost: float, output_cost: float): @@ -136,8 +136,8 @@ class TestCognitionCostTracking: custom_llm_provider="cognition", ) - assert prompt_cost == pytest.approx(2.5) - assert completion_cost == pytest.approx(12.5) + assert prompt_cost == pytest.approx(0.5) + assert completion_cost == pytest.approx(2.5) def test_supported_endpoints_matrix(self): matrix = json.loads((Path(litellm.__file__).parent / "provider_endpoints_support_backup.json").read_text()) @@ -169,5 +169,5 @@ class TestCognitionRouting: ) usage = response.usage - expected = usage.prompt_tokens * 2.5e-06 + usage.completion_tokens * 1.25e-05 + expected = usage.prompt_tokens * 5e-07 + usage.completion_tokens * 2.5e-06 assert response._hidden_params["response_cost"] == pytest.approx(expected) From 16bba154347de9793cce83f94c13caa85e7492e6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:38:47 -0700 Subject: [PATCH 43/70] require an incomplete reason before overriding finish_reason --- .../transformation.py | 4 +- ...responses_transformation_transformation.py | 38 +++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index c6d5b04d370..6103b1bf484 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -836,8 +836,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): handle_raw_dict_callback=self._handle_raw_dict_response_item, ) - response_is_incomplete: Final = ( - raw_response.status == "incomplete" or raw_response.incomplete_details is not None + response_is_incomplete: Final = raw_response.status == "incomplete" or ( + raw_response.incomplete_details is not None and raw_response.incomplete_details.reason is not None ) if len(choices) == 0 and not response_is_incomplete: diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 315a8c6ed95..382b41807d4 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -3,7 +3,7 @@ import json import os import sys import unittest -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import TYPE_CHECKING, List, Literal, Optional, Tuple from unittest.mock import ANY, MagicMock, Mock, patch import httpx @@ -3497,6 +3497,8 @@ async def test_acompletion_bridge_normalizes_tool_choice_on_the_wire( def _make_incomplete_responses_api_response( incomplete_reason: Optional[str], output: "List[ResponseOutputItem]", + status: Literal["completed", "incomplete"] = "incomplete", + empty_incomplete_details: bool = False, ) -> "ResponsesAPIResponse": from litellm.types.llms.openai import ( InputTokensDetails, @@ -3509,7 +3511,11 @@ def _make_incomplete_responses_api_response( id="resp_incomplete", created_at=1760144904, error=None, - incomplete_details={"reason": incomplete_reason} if incomplete_reason else None, + incomplete_details=( + {"reason": incomplete_reason} + if incomplete_reason is not None or empty_incomplete_details + else None + ), instructions=None, metadata={}, model="gpt-5.6-sol", @@ -3523,7 +3529,7 @@ def _make_incomplete_responses_api_response( max_output_tokens=16, previous_response_id=None, reasoning={"effort": "high", "summary": None}, - status="incomplete", + status=status, text={"format": {"type": "text"}, "verbosity": "medium"}, truncation="disabled", usage=ResponseAPIUsage( @@ -3624,6 +3630,32 @@ def test_transform_response_zero_choices_not_incomplete_still_raises(): _call_transform_response(handler, raw_response) +def test_transform_response_completed_with_reasonless_incomplete_details_keeps_stop(): + from openai.types.responses import ResponseOutputMessage, ResponseOutputText + + handler = LiteLLMResponsesTransformationHandler() + output_message = ResponseOutputMessage( + id="msg_complete", + content=[ + ResponseOutputText( + annotations=[], text="full answer", type="output_text", logprobs=[] + ) + ], + role="assistant", + status="completed", + type="message", + ) + raw_response = _make_incomplete_responses_api_response( + None, [output_message], status="completed", empty_incomplete_details=True + ) + + result = _call_transform_response(handler, raw_response) + + assert len(result.choices) == 1 + assert result.choices[0].finish_reason == "stop" + assert result.choices[0].message.content == "full answer" + + def test_transform_response_incomplete_partial_text_overrides_finish_reason_to_length(): from openai.types.responses import ResponseOutputMessage, ResponseOutputText From 1a9e9951e124949f34553c7c4b9c451e44a7355f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:39:29 -0700 Subject: [PATCH 44/70] fix(cognition): restore Lightning SWE pricing and declare responses support The swe-1.7 rates were briefly lowered to the standard tier. The docs page records the API-served swe-1.7 as the Cerebras-served Lightning tier, so put the matching rates back rather than have the cost map and the docs disagree. Cognition also answers /v1/responses through the chat-completions bridge, the same as every other provider in the JSON registry, so the endpoints support matrix should say so instead of under-declaring it. --- litellm/model_prices_and_context_window_backup.json | 6 +++--- litellm/provider_endpoints_support_backup.json | 2 +- model_prices_and_context_window.json | 6 +++--- provider_endpoints_support.json | 2 +- .../llms/openai_like/test_cognition_provider.py | 10 ++++++---- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d63a888ae9c..5d859a05963 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -48486,9 +48486,9 @@ "source": "https://docs.devin.ai/windsurf/plugins/cascade/models" }, "cognition/swe-1.7": { - "input_cost_per_token": 5e-07, - "output_cost_per_token": 2.5e-06, - "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2.5e-06, + "output_cost_per_token": 1.25e-05, + "cache_read_input_token_cost": 1e-06, "litellm_provider": "cognition", "mode": "chat", "supports_function_calling": true, diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index d47d74ead28..b4d635c0fba 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -534,7 +534,7 @@ "endpoints": { "chat_completions": true, "messages": true, - "responses": false, + "responses": true, "embeddings": false, "image_generations": false, "audio_transcriptions": false, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d63a888ae9c..5d859a05963 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -48486,9 +48486,9 @@ "source": "https://docs.devin.ai/windsurf/plugins/cascade/models" }, "cognition/swe-1.7": { - "input_cost_per_token": 5e-07, - "output_cost_per_token": 2.5e-06, - "cache_read_input_token_cost": 2e-07, + "input_cost_per_token": 2.5e-06, + "output_cost_per_token": 1.25e-05, + "cache_read_input_token_cost": 1e-06, "litellm_provider": "cognition", "mode": "chat", "supports_function_calling": true, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 950bf61dbb7..7c1ca34c23c 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -569,7 +569,7 @@ "endpoints": { "chat_completions": true, "messages": true, - "responses": false, + "responses": true, "embeddings": false, "image_generations": false, "audio_transcriptions": false, diff --git a/tests/test_litellm/llms/openai_like/test_cognition_provider.py b/tests/test_litellm/llms/openai_like/test_cognition_provider.py index 6dcc02387cc..c358d178f60 100644 --- a/tests/test_litellm/llms/openai_like/test_cognition_provider.py +++ b/tests/test_litellm/llms/openai_like/test_cognition_provider.py @@ -114,7 +114,7 @@ class TestCognitionCostTracking: "model, input_cost, output_cost", [ ("cognition/swe-1.6", 5e-07, 2.5e-06), - ("cognition/swe-1.7", 5e-07, 2.5e-06), + ("cognition/swe-1.7", 2.5e-06, 1.25e-05), ], ) def test_cost_map_entries(self, model: str, input_cost: float, output_cost: float): @@ -136,14 +136,16 @@ class TestCognitionCostTracking: custom_llm_provider="cognition", ) - assert prompt_cost == pytest.approx(0.5) - assert completion_cost == pytest.approx(2.5) + assert prompt_cost == pytest.approx(2.5) + assert completion_cost == pytest.approx(12.5) def test_supported_endpoints_matrix(self): matrix = json.loads((Path(litellm.__file__).parent / "provider_endpoints_support_backup.json").read_text()) endpoints = matrix["providers"]["cognition"]["endpoints"] assert endpoints["chat_completions"] is True + assert endpoints["messages"] is True + assert endpoints["responses"] is True assert endpoints["embeddings"] is False @@ -169,5 +171,5 @@ class TestCognitionRouting: ) usage = response.usage - expected = usage.prompt_tokens * 5e-07 + usage.completion_tokens * 2.5e-06 + expected = usage.prompt_tokens * 2.5e-06 + usage.completion_tokens * 1.25e-05 assert response._hidden_params["response_cost"] == pytest.approx(expected) From de7dcbbc677b3d52461c74f0595b27a3a38be996 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:41:03 -0700 Subject: [PATCH 45/70] Carry real cache counts up instead of zeroing them on partial rows cache_read_input_tokens and cache_creation_input_tokens are pydantic extras on Usage, not declared fields, so filling them in created keys that were not there before rather than replacing a None. Readers that test for presence then took the new zero as authoritative: the spend log writer skipped its own copy from prompt_tokens_details, turning a real cache read of 500 into 0, and the prometheus provider cache counters stopped incrementing. Carry the prompt_tokens_details counts up before defaulting to zero, so a partial row reports the same cache numbers a complete one does. Renamed the helper to say what it now does. --- .../litellm_core_utils/streaming_handler.py | 32 +++++++++++--- litellm/proxy/common_request_processing.py | 4 +- .../test_streaming_handler.py | 20 ++++++++- .../proxy/test_common_request_processing.py | 44 ++++++++++++++++++- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 3565872f09e..651b169a9b2 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -2326,7 +2326,7 @@ class CustomStreamWrapper: return if self.model: partial_response.model = self.model - zero_fill_missing_cache_usage_fields(usage) + backfill_missing_cache_usage_fields(usage) self.logging_obj.model_call_details["combined_usage_object"] = usage self.logging_obj.model_call_details["response_cost"] = ( self.logging_obj._response_cost_calculator(result=partial_response) or 0.0 @@ -2447,13 +2447,33 @@ class CustomStreamWrapper: return chunk -def zero_fill_missing_cache_usage_fields(usage: Usage) -> None: - if getattr(usage, "cache_creation_input_tokens", None) is None: - usage.cache_creation_input_tokens = 0 # rebind-ok: in-place zero-fill is the contract +def _cache_token_count(details: PromptTokensDetailsWrapper | None, keys: tuple[str, ...]) -> int: + for key in keys: + value = getattr(details, key, None) + if isinstance(value, int) and not isinstance(value, bool) and value: + return value + return 0 + + +def backfill_missing_cache_usage_fields(usage: Usage) -> None: + """Give partial-stream usage the same cache fields a complete stream reports. + + Carries OpenAI-style ``prompt_tokens_details`` counts up to the Anthropic-style + top-level keys, defaulting to zero. It must carry the real count rather than a + flat zero: downstream readers treat these keys as authoritative once present and + skip their own normalization, so a zero here would overwrite a real cache read. + """ + details: Final = usage.prompt_tokens_details if getattr(usage, "cache_read_input_tokens", None) is None: - usage.cache_read_input_tokens = 0 # rebind-ok: in-place zero-fill is the contract + usage.cache_read_input_tokens = _cache_token_count( # rebind-ok: in-place backfill is the contract + details, ("cached_tokens",) + ) + if getattr(usage, "cache_creation_input_tokens", None) is None: + usage.cache_creation_input_tokens = _cache_token_count( # rebind-ok: in-place backfill is the contract + details, ("cache_write_tokens", "cache_creation_tokens") + ) if usage.prompt_tokens_details is None: - usage.prompt_tokens_details = PromptTokensDetailsWrapper(cached_tokens=0) # rebind-ok: zero-fill in place + usage.prompt_tokens_details = PromptTokensDetailsWrapper(cached_tokens=0) # rebind-ok: backfill in place _TokenDetails = TypeVar("_TokenDetails", PromptTokensDetailsWrapper, CompletionTokensDetailsWrapper) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index d430d776350..72cc298d37d 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -44,7 +44,7 @@ from litellm.litellm_core_utils.llm_response_utils.get_headers import ( ) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.litellm_core_utils.streaming_handler import ( - zero_fill_missing_cache_usage_fields, + backfill_missing_cache_usage_fields, ) from litellm.proxy._types import ProxyException, UserAPIKeyAuth from litellm.proxy.auth.auth_checks import can_key_call_resolved_model @@ -332,7 +332,7 @@ async def _bill_partial_streamed_spend_on_disconnect(request_data: dict, respons partial_response.model = wrapper_model partial_usage: Final = getattr(partial_response, "usage", None) if isinstance(partial_usage, Usage): - zero_fill_missing_cache_usage_fields(partial_usage) + backfill_missing_cache_usage_fields(partial_usage) try: await logging_obj.dispatch_success_handlers( partial_response, diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py index a550160cd73..9f04d63b6ad 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_handler.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_handler.py @@ -3459,7 +3459,7 @@ def test_record_partial_usage_for_failure_counts_prompt_tokens_from_request_mess assert stashed.prompt_tokens > 0 -def test_record_partial_usage_for_failure_zero_fills_missing_cache_fields(): +def test_record_partial_usage_for_failure_backfills_missing_cache_fields(): wrapper, logging_obj = _wrapper_with_partial_chunks(chunk_model="gpt-4o-mini") wrapper._record_partial_usage_for_failure() @@ -3471,6 +3471,24 @@ def test_record_partial_usage_for_failure_zero_fills_missing_cache_fields(): assert stashed.prompt_tokens_details.cached_tokens == 0 +def test_record_partial_usage_for_failure_carries_up_openai_style_cached_tokens(): + recovered = Usage( + prompt_tokens=1000, + completion_tokens=10, + total_tokens=1010, + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=500), + ) + wrapper, logging_obj = _wrapper_with_partial_chunks( + chunk_model="gpt-4o-mini", usage=recovered + ) + + wrapper._record_partial_usage_for_failure() + + stashed = logging_obj.model_call_details["combined_usage_object"] + assert stashed.cache_read_input_tokens == 500 + assert stashed.cache_creation_input_tokens == 0 + + def test_record_partial_usage_for_failure_keeps_cache_values_recovered_from_chunks(): recovered = Usage( prompt_tokens=40, diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 609d0dfe1b6..4d78bf164b1 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -5555,7 +5555,7 @@ class TestStreamingClientDisconnectBilling: assert standard_logging_object["response_cost"] > 0.0 @pytest.mark.asyncio - async def test_disconnect_billing_zero_fills_missing_cache_fields(self): + async def test_disconnect_billing_backfills_missing_cache_fields(self): event = await self._bill_and_collect_success_event() usage = event["response_obj"].usage @@ -5564,6 +5564,48 @@ class TestStreamingClientDisconnectBilling: assert usage.prompt_tokens_details is not None assert usage.prompt_tokens_details.cached_tokens == 0 + @pytest.mark.asyncio + async def test_disconnect_billing_carries_up_openai_style_cached_tokens(self): + from litellm.types.utils import ( + Delta, + ModelResponseStream, + PromptTokensDetailsWrapper, + StreamingChoices, + Usage, + ) + + def append_openai_style_cached_usage_chunk(response): + response.chunks.append( + ModelResponseStream( + id=response.chunks[0].id, + model="gpt-4o-mini", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta(content=" and more", role="assistant"), + ) + ], + usage=Usage( + prompt_tokens=1000, + completion_tokens=10, + total_tokens=1010, + prompt_tokens_details=PromptTokensDetailsWrapper( + cached_tokens=500 + ), + ), + ) + ) + + event = await self._bill_and_collect_success_event( + append_openai_style_cached_usage_chunk + ) + + usage = event["response_obj"].usage + assert getattr(usage, "cache_read_input_tokens", None) == 500 + assert getattr(usage, "cache_creation_input_tokens", None) == 0 + @pytest.mark.asyncio async def test_disconnect_billing_keeps_cache_values_recovered_from_chunks(self): from litellm.types.utils import ( From c74e9e75f9ba8172994a32a6bfe74ac7b3206561 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:43:52 -0700 Subject: [PATCH 46/70] feat(ui): support project input and output TPM limits (#37676) The Model-Specific Limits rows now carry Input TPM and Output TPM, and a limit the operator removes is sent as an explicitly empty map so /project/update actually drops it instead of leaving the stored quota enforced behind a UI that shows it gone. Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_project_endpoints_prisma.py | 68 +++++++++ .../hooks/projects/useCreateProject.test.ts | 7 +- .../hooks/projects/useCreateProject.ts | 2 + .../hooks/projects/useUpdateProject.test.ts | 18 ++- .../hooks/projects/useUpdateProject.ts | 2 + .../CreateProjectModal.integration.test.tsx | 4 + .../ProjectModals/CreateProjectModal.tsx | 4 +- .../EditProjectModal.integration.test.tsx | 75 ++++++++++ .../ProjectModals/EditProjectModal.test.tsx | 19 ++- .../ProjectModals/EditProjectModal.tsx | 27 +++- .../ProjectModals/ProjectBaseForm.test.tsx | 14 ++ .../ProjectModals/ProjectBaseForm.tsx | 46 +++++- .../ProjectModals/projectFormSchema.ts | 18 +-- .../ProjectModals/projectFormUtils.test.ts | 131 +++++++++++++++--- .../ProjectModals/projectFormUtils.ts | 74 ++++++---- 15 files changed, 435 insertions(+), 74 deletions(-) diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index c29b4c68bb0..bd6637ffcac 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -29,6 +29,7 @@ from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( from litellm.proxy.proxy_server import ( LitellmUserRoles, ) +from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.utils import PrismaClient, ProxyLogging verbose_proxy_logger.setLevel(level=logging.DEBUG) @@ -1039,3 +1040,70 @@ async def test_project_eviction_publishes_cross_worker_invalidation(monkeypatch) ) mock_publish.assert_awaited_once_with(cache_key=f"project_id:{project_id}") + + +def _project_update_mocks(monkeypatch, stored_metadata: dict) -> mock.MagicMock: + existing_row = mock.MagicMock( + team_id=None, budget_id=None, object_permission_id=None, metadata=stored_metadata + ) + mock_prisma = mock.MagicMock() + mock_prisma.jsonify_object = lambda data: data + mock_prisma.db.litellm_projecttable.find_unique = mock.AsyncMock(return_value=existing_row) + mock_prisma.db.litellm_projecttable.update = mock.AsyncMock(return_value=mock.MagicMock()) + + monkeypatch.setattr(litellm.proxy.proxy_server, "premium_user", True) + monkeypatch.setattr(litellm.proxy.proxy_server, "prisma_client", mock_prisma) + monkeypatch.setattr(litellm.proxy.proxy_server, "user_api_key_cache", UserApiKeyCache()) + return mock_prisma + + +async def _run_project_update(project_id: str, **fields) -> None: + await update_project( + data=UpdateProjectRequest(project_id=project_id, **fields), + http_request=Request(scope={"type": "http"}), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + api_key="sk-1234", + user_id="1234", + ), + ) + + +def _written_project_data(mock_prisma: mock.MagicMock) -> dict: + return mock_prisma.db.litellm_projecttable.update.await_args.kwargs["data"] + + +@pytest.mark.asyncio +async def test_update_project_clears_model_itpm_limit_sent_as_an_empty_map(monkeypatch): + """ + LIT-4693 regression: an omitted key means "leave this alone", so the only way to drop a + per-model input/output TPM quota is to send it as an explicitly empty map. The written + metadata must stop carrying the quota, otherwise the proxy keeps enforcing a limit the + operator has already removed in the UI. + """ + project_id = f"project-{uuid.uuid4()}" + mock_prisma = _project_update_mocks( + monkeypatch, + {"owner": "platform", "model_itpm_limit": {"gpt-4": 60}, "model_otpm_limit": {"gpt-4": 40}}, + ) + + await _run_project_update(project_id, model_itpm_limit={}, model_otpm_limit={}) + + written_metadata = _written_project_data(mock_prisma)["metadata"] + assert written_metadata["model_itpm_limit"] == {} + assert written_metadata["model_otpm_limit"] == {} + + +@pytest.mark.asyncio +async def test_update_project_leaves_metadata_untouched_when_no_limit_is_sent(monkeypatch): + """ + The other half of the same contract: an update that says nothing about the limits must not + write metadata at all. That is what makes a dropped key silently preserve the old quota, so + the UI has to send the empty map instead of omitting it. + """ + project_id = f"project-{uuid.uuid4()}" + mock_prisma = _project_update_mocks(monkeypatch, {"model_itpm_limit": {"gpt-4": 60}}) + + await _run_project_update(project_id, description="renamed only") + + assert "metadata" not in _written_project_data(mock_prisma) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts index 110a704725a..2fdb999e6cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.test.ts @@ -67,7 +67,12 @@ describe("useCreateProject", () => { const { result } = renderHook(() => useCreateProject(), { wrapper: makeWrapper(queryClient), }); - const params: ProjectCreateParams = { team_id: "team-1", project_alias: "New Project" }; + const params: ProjectCreateParams = { + team_id: "team-1", + project_alias: "New Project", + model_itpm_limit: { "gpt-4": 150 }, + model_otpm_limit: { "gpt-4": 250 }, + }; const data = await result.current.mutateAsync(params); expect(data).toEqual(mockProject); const [url, init] = (global.fetch as any).mock.calls[0]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts index 2e67e626936..d1d16f08867 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts @@ -16,6 +16,8 @@ export interface ProjectCreateParams { metadata?: Record; model_rpm_limit?: Record; model_tpm_limit?: Record; + model_itpm_limit?: Record; + model_otpm_limit?: Record; } // ── Fetch function ─────────────────────────────────────────────────────────── diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts index 9e752ac098a..bf3add2d8c6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.test.ts @@ -68,17 +68,25 @@ describe("useUpdateProject", () => { const { result } = renderHook(() => useUpdateProject(), { wrapper: makeWrapper(queryClient), }); + const params = { + project_alias: "Updated Name", + model_itpm_limit: { "gpt-4": 150 }, + model_otpm_limit: { "gpt-4": 250 }, + }; + const expectedBody = { + project_id: "proj-1", + project_alias: "Updated Name", + model_itpm_limit: { "gpt-4": 150 }, + model_otpm_limit: { "gpt-4": 250 }, + }; const data = await result.current.mutateAsync({ projectId: "proj-1", - params: { project_alias: "Updated Name" }, + params, }); expect(data).toEqual(updated); const [url, init] = (global.fetch as any).mock.calls[0]; expect(url).toContain("/project/update"); - expect(JSON.parse(init.body)).toMatchObject({ - project_id: "proj-1", - project_alias: "Updated Name", - }); + expect(JSON.parse(init.body)).toMatchObject(expectedBody); }); it("should invalidate project queries on success", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts index 6d8c2d9d4f8..8e6bad04a28 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts @@ -16,6 +16,8 @@ export interface ProjectUpdateParams { metadata?: Record; model_rpm_limit?: Record; model_tpm_limit?: Record; + model_itpm_limit?: Record; + model_otpm_limit?: Record; } // ── Fetch function ─────────────────────────────────────────────────────────── diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.integration.test.tsx index c7d0d00057c..883e3173d98 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.integration.test.tsx @@ -193,11 +193,15 @@ describe("CreateProjectModal submit payload", () => { fireEvent.change(screen.getByPlaceholderText("Model name (e.g. gpt-4)"), { target: { value: "gpt-4" } }); fireEvent.change(screen.getByPlaceholderText("TPM Limit"), { target: { value: "100" } }); fireEvent.change(screen.getByPlaceholderText("RPM Limit"), { target: { value: "20" } }); + fireEvent.change(screen.getByPlaceholderText("Input TPM Limit"), { target: { value: "60" } }); + fireEvent.change(screen.getByPlaceholderText("Output TPM Limit"), { target: { value: "40" } }); await submit(user); await waitFor(() => expect(mutate).toHaveBeenCalled()); expect(params().model_tpm_limit).toStrictEqual({ "gpt-4": 100 }); expect(params().model_rpm_limit).toStrictEqual({ "gpt-4": 20 }); + expect(params().model_itpm_limit).toStrictEqual({ "gpt-4": 60 }); + expect(params().model_otpm_limit).toStrictEqual({ "gpt-4": 40 }); }); it("sends metadata pairs as an object", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx index 14bcc40bfea..c923af02c4a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/CreateProjectModal.tsx @@ -10,7 +10,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useCreateProject, ProjectCreateParams } from "@/app/(dashboard)/hooks/projects/useCreateProject"; import { ProjectBaseForm } from "./ProjectBaseForm"; import { emptyProjectFormValues, projectFormSchema } from "./projectFormSchema"; -import { buildProjectApiParams } from "./projectFormUtils"; +import { buildProjectCreateParams } from "./projectFormUtils"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; interface CreateProjectModalProps { @@ -25,7 +25,7 @@ function CreateProjectForm({ onClose }: { onClose: () => void }) { const handleSubmit = form.handleSubmit((values) => { const params: ProjectCreateParams = { - ...buildProjectApiParams(values), + ...buildProjectCreateParams(values), team_id: values.team_id, }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx index 9abea27ceda..5b84aa15dd1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx @@ -52,6 +52,8 @@ const project: ProjectResponse = { guardrails: ["pii-guard"], model_rpm_limit: { "gpt-4": 20 }, model_tpm_limit: { "gpt-4": 100 }, + model_itpm_limit: { "gpt-4": 60 }, + model_otpm_limit: { "gpt-4": 40 }, }, models: ["gpt-4"], spend: 10, @@ -120,6 +122,8 @@ describe("EditProjectModal submit payload", () => { guardrails: ["pii-guard"], model_rpm_limit: { "gpt-4": 20 }, model_tpm_limit: { "gpt-4": 100 }, + model_itpm_limit: { "gpt-4": 60 }, + model_otpm_limit: { "gpt-4": 40 }, metadata: { owner: "platform" }, team_id: "team-1", }); @@ -200,6 +204,77 @@ describe("EditProjectModal submit payload", () => { expect(variables().params).not.toHaveProperty("guardrails"); expect(variables().params).not.toHaveProperty("model_rpm_limit"); expect(variables().params).not.toHaveProperty("model_tpm_limit"); + expect(variables().params).not.toHaveProperty("model_itpm_limit"); + expect(variables().params).not.toHaveProperty("model_otpm_limit"); expect(variables().params).not.toHaveProperty("metadata"); }); + + it("sends empty limit maps once the model limit row is removed, so the stored limits are cleared", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("My Project"); + + await user.click(screen.getByText("Advanced Settings")); + await screen.findByText("Model-Specific Limits"); + await user.click(screen.getByRole("button", { name: "Remove model limit 1" })); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.model_itpm_limit).toStrictEqual({}); + expect(variables().params.model_otpm_limit).toStrictEqual({}); + expect(variables().params.model_tpm_limit).toStrictEqual({}); + expect(variables().params.model_rpm_limit).toStrictEqual({}); + expect(variables().params.metadata).toStrictEqual({ owner: "platform" }); + }); + + it("sends an empty input TPM map when only that field is blanked on a row that keeps its other limits", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("My Project"); + + await user.click(screen.getByText("Advanced Settings")); + await screen.findByText("Model-Specific Limits"); + await user.clear(screen.getByLabelText("Input TPM Limit")); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.model_itpm_limit).toStrictEqual({}); + expect(variables().params.model_otpm_limit).toStrictEqual({ "gpt-4": 40 }); + expect(variables().params.model_tpm_limit).toStrictEqual({ "gpt-4": 100 }); + }); + + it("sends an empty metadata object once the last metadata row is removed", async () => { + const user = setup(); + renderModal({ ...project, metadata: { owner: "platform" } } as unknown as ProjectResponse); + await screen.findByDisplayValue("My Project"); + + await user.click(screen.getByText("Advanced Settings")); + await screen.findByText("Metadata"); + await user.click(screen.getByRole("button", { name: "Remove metadata pair 1" })); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.metadata).toStrictEqual({}); + }); + + it("round-trips input and output-only model limits from project metadata", async () => { + const user = setup(); + renderModal({ + ...project, + metadata: { + model_itpm_limit: { "input-model": 150 }, + model_otpm_limit: { "output-model": 250 }, + }, + } as unknown as ProjectResponse); + await screen.findByDisplayValue("My Project"); + + await user.click(screen.getByText("Advanced Settings")); + await screen.findByText("Model-Specific Limits"); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.model_itpm_limit).toStrictEqual({ "input-model": 150 }); + expect(variables().params.model_otpm_limit).toStrictEqual({ "output-model": 250 }); + expect(variables().params.metadata).toStrictEqual({}); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.test.tsx index c8de4644d91..9cf6ed24453 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithProviders, screen } from "../../../../../../tests/test-utils"; -import { EditProjectModal } from "./EditProjectModal"; +import { EditProjectModal, toFormValues } from "./EditProjectModal"; import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; const mockMutate = vi.fn(); @@ -72,4 +72,21 @@ describe("EditProjectModal", () => { renderWithProviders(); expect(screen.getByTestId("project-base-form")).toBeInTheDocument(); }); + + it("should prefill input and output TPM limits and keep them out of metadata", () => { + const values = toFormValues({ + ...mockProject, + metadata: { + model_itpm_limit: { "input-model": 150 }, + model_otpm_limit: { "output-model": 250 }, + owner: "platform", + }, + }); + + expect(values.modelLimits).toEqual([ + { model: "input-model", rpm: undefined, tpm: undefined, itpm: 150, otpm: undefined }, + { model: "output-model", rpm: undefined, tpm: undefined, itpm: undefined, otpm: 250 }, + ]); + expect(values.metadata).toEqual([{ key: "owner", value: "platform" }]); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx index 307ba88e0f3..5c1a443d6c5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx @@ -11,7 +11,7 @@ import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useUpdateProject, ProjectUpdateParams } from "@/app/(dashboard)/hooks/projects/useUpdateProject"; import { ProjectBaseForm } from "./ProjectBaseForm"; import { projectFormSchema, type ProjectFormValues } from "./projectFormSchema"; -import { buildProjectApiParams } from "./projectFormUtils"; +import { buildProjectUpdateParams } from "./projectFormUtils"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; interface EditProjectModalProps { @@ -21,18 +21,35 @@ interface EditProjectModalProps { onSuccess?: () => void; } -const INTERNAL_METADATA_KEYS = new Set(["model_rpm_limit", "model_tpm_limit", "guardrails"]); +const INTERNAL_METADATA_KEYS = new Set([ + "model_rpm_limit", + "model_tpm_limit", + "model_itpm_limit", + "model_otpm_limit", + "guardrails", +]); -const toFormValues = (project: ProjectResponse): ProjectFormValues => { +export const toFormValues = (project: ProjectResponse): ProjectFormValues => { const metadataObj = (project.metadata ?? {}) as Record; const rpmLimits = (metadataObj.model_rpm_limit ?? {}) as Record; const tpmLimits = (metadataObj.model_tpm_limit ?? {}) as Record; + const itpmLimits = (metadataObj.model_itpm_limit ?? {}) as Record; + const otpmLimits = (metadataObj.model_otpm_limit ?? {}) as Record; const guardrails = (Array.isArray(metadataObj.guardrails) ? metadataObj.guardrails : []) as string[]; - const modelLimits = Array.from(new Set([...Object.keys(rpmLimits), ...Object.keys(tpmLimits)])).map((model) => ({ + const modelLimits = Array.from( + new Set([ + ...Object.keys(rpmLimits), + ...Object.keys(tpmLimits), + ...Object.keys(itpmLimits), + ...Object.keys(otpmLimits), + ]), + ).map((model) => ({ model, rpm: rpmLimits[model], tpm: tpmLimits[model], + itpm: itpmLimits[model], + otpm: otpmLimits[model], })); const metadata = Object.entries(metadataObj) @@ -69,7 +86,7 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit { expect(screen.getByText("Guardrails")).toBeInTheDocument(); }); }); + + it("should show combined, input, and output TPM limit inputs for a model row", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByText("Advanced Settings")); + await user.click(screen.getByRole("button", { name: /add model limit/i })); + + expect(screen.getByPlaceholderText("TPM Limit")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Input TPM Limit")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Output TPM Limit")).toBeInTheDocument(); + expect(screen.getByLabelText("TPM Limit")).toBeInTheDocument(); + expect(screen.getByLabelText("Input TPM Limit")).toBeInTheDocument(); + expect(screen.getByLabelText("Output TPM Limit")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx index d4b7e71dfe9..a18b5ecfb98 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx @@ -49,6 +49,13 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr const modelLimits = useFieldArray({ control: form.control, name: "modelLimits" }); const metadata = useFieldArray({ control: form.control, name: "metadata" }); + const emptyModelLimit: NonNullable[number] = { + model: "", + tpm: undefined, + rpm: undefined, + itpm: undefined, + otpm: undefined, + }; const teamIdValue = useWatch({ control: form.control, name: "team_id" }); const isBlocked = useWatch({ control: form.control, name: "isBlocked" }); @@ -262,13 +269,16 @@ export function ProjectBaseForm({ form, advancedOpen, onAdvancedOpenChange }: Pr

Model-Specific Limits

{modelLimits.fields.map((field, index) => ( -
- +
+ {({ ref, ...control }) => ( )} - + {({ ref, value, onChange, ...control }) => ( )} - + {({ ref, value, onChange, ...control }) => ( )} + + {({ ref, value, onChange, ...control }) => ( + onChange(toOptionalNumber(event.target.value))} + /> + )} + + + {({ ref, value, onChange, ...control }) => ( + onChange(toOptionalNumber(event.target.value))} + /> + )} +