diff --git a/.gitignore b/.gitignore
index 3329f39ca10..9b552a8c269 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
.python-version
.venv
+tests/e2e/.fixtures/
.venv-typecheck
.venv_policy_test
.env
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260817000000_shadow_eval_multi_key/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260817000000_shadow_eval_multi_key/migration.sql
new file mode 100644
index 00000000000..18ef5c40662
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260817000000_shadow_eval_multi_key/migration.sql
@@ -0,0 +1,7 @@
+ALTER TABLE "LiteLLM_ShadowEvalJob" ADD COLUMN IF NOT EXISTS "group_id" TEXT;
+
+UPDATE "LiteLLM_ShadowEvalJob" SET "group_id" = "id" WHERE "group_id" IS NULL;
+
+ALTER TABLE "LiteLLM_ShadowEvalJob" ALTER COLUMN "group_id" SET NOT NULL;
+
+CREATE INDEX IF NOT EXISTS "LiteLLM_ShadowEvalJob_group_id_idx" ON "LiteLLM_ShadowEvalJob"("group_id");
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260818224500_add_shadow_eval_stopped_by/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260818224500_add_shadow_eval_stopped_by/migration.sql
new file mode 100644
index 00000000000..9efa3fdd052
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260818224500_add_shadow_eval_stopped_by/migration.sql
@@ -0,0 +1,5 @@
+-- AlterTable
+ALTER TABLE "LiteLLM_ShadowEvalJob" ADD COLUMN "stopped_by" TEXT;
+
+UPDATE "LiteLLM_ShadowEvalJob" SET stopped_by = 'unknown'
+WHERE stopped_at IS NOT NULL AND ends_at > (NOW() AT TIME ZONE 'utc');
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index 52fb447157b..f79e2bb0c18 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -1467,28 +1467,38 @@ model LiteLLM_AutoRouterSession {
@@index([last_turn_at], map: "idx_autorouter_session_last_turn")
}
-// Shadow eval: evaluation of an auto-router against a key's live traffic, in either
-// direction. forward duplicates the requests the key did not route through the router
-// through it, answering whether the key should adopt it; reverse duplicates the requests
-// the router did serve against a fixed baseline model, answering whether a key already on
-// it still benefits. Either way a sampled slice runs in a detached task and an LLM judge
-// compares real vs shadow responses blind. The job row is immutable config plus
-// stopped_at; every count, status, and spend figure is derived from the append-only
-// attempt rows, so nothing can disagree across pods or stop races.
+// Shadow eval: evaluation of an auto-router against one or more keys' live traffic, in
+// either direction. forward duplicates the requests the keys did not route through the
+// router through it, answering whether they should adopt it; reverse duplicates the
+// requests the router did serve against a fixed baseline model, answering whether a key
+// already on it still benefits. Either way a sampled slice runs in a detached task and an
+// LLM judge compares real vs shadow responses blind. Each row is ONE key's leg of a job:
+// immutable config plus that key's own turn budget and stop state, so one key exhausting
+// its budget never ends a sibling's sampling. A job is the set of legs sharing group_id
+// (the id the API reports), written together by one atomic create_many with identical
+// config; single-key jobs predating group_id were backfilled group_id = id. "One active
+// job per (key, direction)" is a partial unique index on (api_key_id, direction) WHERE
+// stopped_at IS NULL, expressed only in the migration because schema.prisma cannot state
+// partial indexes; it is what makes a concurrent start on another pod race-safe rather
+// than read-then-create. Every count, status, and spend figure is derived from the
+// append-only attempt rows, so nothing can disagree across pods or stop races.
model LiteLLM_ShadowEvalJob {
id String @id @default(cuid())
- api_key_id String // hashed virtual key whose traffic is shadowed
+ group_id String // legs of one job share this; the API's job id
+ api_key_id String // hashed virtual key whose traffic this leg shadows
router_name String // the auto-router under evaluation, in either direction
direction String @default("forward") // forward | reverse
baseline_model String? // reverse only: the fixed model the router is judged against
judge_model String
shadow_percentage Float
- max_turns Int // sample budget: judge at most this many turns
+ max_turns Int // this key's sample budget: judge at most this many turns
created_at DateTime @default(now())
created_by String?
ends_at DateTime
stopped_at DateTime?
+ stopped_by String? // operator who stopped it early; null when it ended on its own
+ @@index([group_id])
@@index([api_key_id])
@@index([created_at])
}
diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py
index 43bcf892865..485091bccd0 100644
--- a/litellm/litellm_core_utils/streaming_handler.py
+++ b/litellm/litellm_core_utils/streaming_handler.py
@@ -1830,6 +1830,20 @@ class CustomStreamWrapper:
return
self.chunks.append(model_response.model_copy(update={"choices": []}))
+ @staticmethod
+ def _resolve_provider_reported_cost(usage_cost: object) -> float | None:
+ """
+ Providers report usage.cost either as a number or, for Perplexity, as a
+ breakdown object whose total lives under ``total_cost``.
+ """
+ if isinstance(usage_cost, bool):
+ return None
+ if isinstance(usage_cost, (int, float)):
+ return float(usage_cost)
+ if isinstance(usage_cost, dict):
+ return CustomStreamWrapper._resolve_provider_reported_cost(usage_cost.get("total_cost"))
+ return None
+
@staticmethod
def _propagate_usage_cost_to_hidden_params(
response: "ModelResponse",
@@ -1840,10 +1854,11 @@ class CustomStreamWrapper:
calculator uses it instead of a token-based estimate.
"""
_usage: Final[Usage | None] = getattr(response, "usage", None)
- if _usage is not None and hasattr(_usage, "cost") and _usage.cost is not None:
+ _cost: Final = CustomStreamWrapper._resolve_provider_reported_cost(getattr(_usage, "cost", None))
+ if _cost is not None:
if "additional_headers" not in response._hidden_params:
response._hidden_params["additional_headers"] = {}
- response._hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] = float(_usage.cost)
+ response._hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] = _cost
def __next__(self) -> "ModelResponseStream":
cache_hit = False
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index 8c07ca35443..1ffd0a00a22 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -54,6 +54,7 @@
"output_cost_per_image": 0.04
},
"1024-x-1024/dall-e-2": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 1.9e-08,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -67,6 +68,7 @@
"output_cost_per_image": 0.08
},
"256-x-256/dall-e-2": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 2.4414e-07,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -80,6 +82,7 @@
"output_cost_per_image": 0.018
},
"512-x-512/dall-e-2": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 6.86e-08,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -2887,6 +2890,7 @@
"supports_function_calling": true
},
"azure_ai/claude-haiku-4-5": {
+ "deprecation_date": "2026-10-19",
"cache_creation_input_token_cost": 1.25e-06,
"cache_creation_input_token_cost_above_1hr": 2e-06,
"cache_read_input_token_cost": 1e-07,
@@ -2908,6 +2912,7 @@
"supports_vision": true
},
"azure_ai/claude-opus-4-5": {
+ "deprecation_date": "2026-10-19",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -2930,6 +2935,7 @@
"supports_output_config": true
},
"azure_ai/claude-opus-4-6": {
+ "deprecation_date": "2027-02-02",
"supports_adaptive_thinking": true,
"input_cost_per_token": 5e-06,
"output_cost_per_token": 2.5e-05,
@@ -2959,6 +2965,7 @@
"supports_max_reasoning_effort": true
},
"azure_ai/claude-opus-4-7": {
+ "deprecation_date": "2027-04-06",
"supports_adaptive_thinking": true,
"input_cost_per_token": 5e-06,
"output_cost_per_token": 2.5e-05,
@@ -3083,6 +3090,7 @@
"supports_max_reasoning_effort": true
},
"azure_ai/claude-opus-4-1": {
+ "deprecation_date": "2026-08-05",
"cache_creation_input_token_cost": 1.875e-05,
"cache_creation_input_token_cost_above_1hr": 3e-05,
"cache_read_input_token_cost": 1.5e-06,
@@ -3104,6 +3112,7 @@
"supports_vision": true
},
"azure_ai/claude-sonnet-4-5": {
+ "deprecation_date": "2026-10-19",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -3156,6 +3165,7 @@
"supports_max_reasoning_effort": true
},
"azure_ai/claude-sonnet-4-6": {
+ "deprecation_date": "2027-02-10",
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
@@ -3226,6 +3236,7 @@
"supports_tool_choice": true
},
"azure_ai/gpt-5.5": {
+ "deprecation_date": "2027-10-26",
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
"cache_read_input_token_cost_priority": 1e-06,
@@ -3318,6 +3329,7 @@
"supports_minimal_reasoning_effort": false
},
"azure_ai/gpt-5.4": {
+ "deprecation_date": "2027-09-02",
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
"cache_read_input_token_cost_priority": 5e-07,
@@ -3364,6 +3376,7 @@
"supports_minimal_reasoning_effort": true
},
"azure_ai/gpt-5.4-2026-03-05": {
+ "deprecation_date": "2027-09-02",
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
"cache_read_input_token_cost_priority": 5e-07,
@@ -3410,6 +3423,7 @@
"supports_minimal_reasoning_effort": true
},
"azure_ai/gpt-5.4-pro": {
+ "deprecation_date": "2027-09-07",
"cache_read_input_token_cost": 3e-06,
"cache_read_input_token_cost_above_272k_tokens": 6e-06,
"cache_read_input_token_cost_priority": 6e-06,
@@ -3455,6 +3469,7 @@
"supports_minimal_reasoning_effort": true
},
"azure_ai/gpt-5.4-pro-2026-03-05": {
+ "deprecation_date": "2027-09-07",
"cache_read_input_token_cost": 3e-06,
"cache_read_input_token_cost_above_272k_tokens": 6e-06,
"cache_read_input_token_cost_priority": 6e-06,
@@ -3500,6 +3515,7 @@
"supports_minimal_reasoning_effort": true
},
"azure_ai/gpt-5.4-mini": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 7.5e-08,
"cache_read_input_token_cost_priority": 1.5e-07,
"input_cost_per_token": 7.5e-07,
@@ -3540,6 +3556,7 @@
"supports_minimal_reasoning_effort": false
},
"azure_ai/gpt-5.4-mini-2026-03-17": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 7.5e-08,
"cache_read_input_token_cost_priority": 1.5e-07,
"input_cost_per_token": 7.5e-07,
@@ -3580,6 +3597,7 @@
"supports_minimal_reasoning_effort": false
},
"azure_ai/gpt-5.4-nano": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 2e-08,
"cache_read_input_token_cost_priority": 4e-08,
"input_cost_per_token": 2e-07,
@@ -3620,6 +3638,7 @@
"supports_minimal_reasoning_effort": false
},
"azure_ai/gpt-5.4-nano-2026-03-17": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 2e-08,
"cache_read_input_token_cost_priority": 4e-08,
"input_cost_per_token": 2e-07,
@@ -3849,6 +3868,7 @@
"supports_vision": true
},
"azure/eu/gpt-5.1": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.38e-06,
"litellm_provider": "azure",
@@ -3918,6 +3938,7 @@
"supports_none_reasoning_effort": true
},
"azure/eu/gpt-5.1-codex": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.38e-06,
"litellm_provider": "azure",
@@ -3948,6 +3969,7 @@
"supports_vision": true
},
"azure/eu/gpt-5.1-codex-mini": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 2.8e-08,
"input_cost_per_token": 2.75e-07,
"litellm_provider": "azure",
@@ -4107,6 +4129,7 @@
"supports_vision": true
},
"azure/global-standard/gpt-4o-mini": {
+ "deprecation_date": "2027-04-14",
"input_cost_per_token": 1.5e-07,
"litellm_provider": "azure",
"max_input_tokens": 128000,
@@ -4155,6 +4178,7 @@
"supports_vision": true
},
"azure/global/gpt-5.1": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "azure",
@@ -4224,6 +4248,7 @@
"supports_none_reasoning_effort": true
},
"azure/global/gpt-5.1-codex": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "azure",
@@ -4254,6 +4279,7 @@
"supports_vision": true
},
"azure/global/gpt-5.1-codex-mini": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 2.5e-08,
"input_cost_per_token": 2.5e-07,
"litellm_provider": "azure",
@@ -4492,6 +4518,7 @@
"supports_vision": true
},
"azure/gpt-4.1": {
+ "deprecation_date": "2027-04-14",
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
@@ -4559,6 +4586,7 @@
"supports_web_search": false
},
"azure/gpt-4.1-mini": {
+ "deprecation_date": "2027-04-14",
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 4e-07,
"input_cost_per_token_batches": 2e-07,
@@ -4626,6 +4654,7 @@
"supports_web_search": false
},
"azure/gpt-4.1-nano": {
+ "deprecation_date": "2026-10-14",
"cache_read_input_token_cost": 2.5e-08,
"input_cost_per_token": 1e-07,
"input_cost_per_token_batches": 5e-08,
@@ -4902,6 +4931,7 @@
"supports_vision": false
},
"azure/gpt-4o-mini": {
+ "deprecation_date": "2027-04-14",
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 1.65e-07,
"litellm_provider": "azure",
@@ -5344,6 +5374,7 @@
"supports_vision": true
},
"azure/gpt-5": {
+ "deprecation_date": "2027-02-09",
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "azure",
@@ -5507,6 +5538,7 @@
"supports_vision": true
},
"azure/gpt-5-mini": {
+ "deprecation_date": "2027-02-09",
"cache_read_input_token_cost": 2.5e-08,
"input_cost_per_token": 2.5e-07,
"litellm_provider": "azure",
@@ -5572,6 +5604,7 @@
"supports_vision": true
},
"azure/gpt-5-nano": {
+ "deprecation_date": "2027-02-09",
"cache_read_input_token_cost": 5e-09,
"input_cost_per_token": 5e-08,
"litellm_provider": "azure",
@@ -5667,6 +5700,7 @@
"supports_vision": true
},
"azure/gpt-5.1": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "azure",
@@ -5736,6 +5770,7 @@
"supports_none_reasoning_effort": true
},
"azure/gpt-5.1-codex": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "azure",
@@ -5797,6 +5832,7 @@
"supports_vision": true
},
"azure/gpt-5.1-codex-mini": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 2.5e-08,
"input_cost_per_token": 2.5e-07,
"litellm_provider": "azure",
@@ -5827,6 +5863,7 @@
"supports_vision": true
},
"azure/gpt-5.2": {
+ "deprecation_date": "2027-06-08",
"cache_read_input_token_cost": 1.75e-07,
"input_cost_per_token": 1.75e-06,
"litellm_provider": "azure",
@@ -6136,6 +6173,7 @@
"supports_web_search": true
},
"azure/gpt-5.4": {
+ "deprecation_date": "2027-09-02",
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
"cache_read_input_token_cost_priority": 5e-07,
@@ -6180,6 +6218,7 @@
"supports_minimal_reasoning_effort": true
},
"azure/us/gpt-5.4": {
+ "deprecation_date": "2027-09-02",
"cache_read_input_token_cost": 2.8e-07,
"cache_read_input_token_cost_priority": 5.5e-07,
"input_cost_per_token": 2.75e-06,
@@ -6218,6 +6257,7 @@
"supports_minimal_reasoning_effort": true
},
"azure/eu/gpt-5.4": {
+ "deprecation_date": "2027-09-02",
"cache_read_input_token_cost": 2.8e-07,
"cache_read_input_token_cost_priority": 5.5e-07,
"input_cost_per_token": 2.75e-06,
@@ -6379,6 +6419,7 @@
"supports_minimal_reasoning_effort": true
},
"azure/gpt-5.4-pro": {
+ "deprecation_date": "2027-09-07",
"cache_read_input_token_cost": 3e-06,
"cache_read_input_token_cost_above_272k_tokens": 6e-06,
"input_cost_per_token": 3e-05,
@@ -7045,6 +7086,7 @@
"supports_minimal_reasoning_effort": false
},
"azure/gpt-5.5": {
+ "deprecation_date": "2027-10-26",
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
"cache_read_input_token_cost_priority": 1e-06,
@@ -7095,6 +7137,7 @@
"supports_minimal_reasoning_effort": false
},
"azure/us/gpt-5.5": {
+ "deprecation_date": "2027-10-26",
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.38e-06,
@@ -7142,6 +7185,7 @@
"supports_minimal_reasoning_effort": false
},
"azure/eu/gpt-5.5": {
+ "deprecation_date": "2027-10-26",
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.38e-06,
@@ -7408,6 +7452,7 @@
"supports_web_search": true
},
"azure/gpt-5.4-mini": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "azure",
@@ -7489,6 +7534,7 @@
"supports_xhigh_reasoning_effort": true
},
"azure/gpt-5.4-nano": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 2e-08,
"input_cost_per_token": 2e-07,
"litellm_provider": "azure",
@@ -7601,6 +7647,7 @@
"output_cost_per_token": 0.0
},
"azure/high/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.59263611e-07,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7610,6 +7657,7 @@
]
},
"azure/high/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.58945719e-07,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7619,6 +7667,7 @@
]
},
"azure/high/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.58945719e-07,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7628,6 +7677,7 @@
]
},
"azure/low/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.0490417e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7637,6 +7687,7 @@
]
},
"azure/low/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.0172526e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7646,6 +7697,7 @@
]
},
"azure/low/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.0172526e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7655,6 +7707,7 @@
]
},
"azure/medium/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7664,6 +7717,7 @@
]
},
"azure/medium/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7673,6 +7727,7 @@
]
},
"azure/medium/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7695,6 +7750,7 @@
]
},
"azure/gpt-image-1.5": {
+ "deprecation_date": "2027-06-16",
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_image_token": 8e-06,
@@ -7720,6 +7776,7 @@
]
},
"azure/gpt-image-2": {
+ "deprecation_date": "2027-10-21",
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_image_token": 8e-06,
@@ -7751,6 +7808,7 @@
"supports_pdf_input": true
},
"azure/low/1024-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 2.0751953125e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7760,6 +7818,7 @@
]
},
"azure/low/1024-x-1536/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 2.0751953125e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7769,6 +7828,7 @@
]
},
"azure/low/1536-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 2.0345052083e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7778,6 +7838,7 @@
]
},
"azure/medium/1024-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 8.056640625e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7787,6 +7848,7 @@
]
},
"azure/medium/1024-x-1536/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 8.056640625e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7796,6 +7858,7 @@
]
},
"azure/medium/1536-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 7.9752604167e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7805,6 +7868,7 @@
]
},
"azure/high/1024-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 3.173828125e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7814,6 +7878,7 @@
]
},
"azure/high/1024-x-1536/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 3.173828125e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7823,6 +7888,7 @@
]
},
"azure/high/1536-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 3.1575520833e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7850,6 +7916,7 @@
"supports_function_calling": true
},
"azure/o1": {
+ "deprecation_date": "2026-10-21",
"cache_read_input_token_cost": 7.5e-06,
"input_cost_per_token": 1.5e-05,
"litellm_provider": "azure",
@@ -7944,6 +8011,7 @@
"supports_vision": false
},
"azure/o3": {
+ "deprecation_date": "2026-10-21",
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "azure",
@@ -8041,6 +8109,7 @@
"supports_web_search": true
},
"azure/o3-mini": {
+ "deprecation_date": "2026-10-01",
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 1.1e-06,
"litellm_provider": "azure",
@@ -8071,6 +8140,7 @@
"supports_vision": false
},
"azure/o3-pro": {
+ "deprecation_date": "2026-12-17",
"input_cost_per_token": 2e-05,
"input_cost_per_token_batches": 1e-05,
"litellm_provider": "azure",
@@ -8132,6 +8202,7 @@
"supports_vision": true
},
"azure/o4-mini": {
+ "deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 2.75e-07,
"input_cost_per_token": 1.1e-06,
"litellm_provider": "azure",
@@ -8580,6 +8651,7 @@
"supports_vision": true
},
"azure/us/gpt-5.1": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.38e-06,
"litellm_provider": "azure",
@@ -8649,6 +8721,7 @@
"supports_none_reasoning_effort": true
},
"azure/us/gpt-5.1-codex": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.38e-06,
"litellm_provider": "azure",
@@ -8679,6 +8752,7 @@
"supports_vision": true
},
"azure/us/gpt-5.1-codex-mini": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 2.8e-08,
"input_cost_per_token": 2.75e-07,
"litellm_provider": "azure",
@@ -8876,6 +8950,7 @@
]
},
"azure_ai/FW-DeepSeek-V3.2": {
+ "deprecation_date": "2027-07-01",
"cache_read_input_token_cost": 3.1e-07,
"input_cost_per_token": 6.2e-07,
"litellm_provider": "azure_ai",
@@ -8906,6 +8981,7 @@
"supports_tool_choice": true
},
"azure_ai/FW-GLM-5": {
+ "deprecation_date": "2027-07-01",
"cache_read_input_token_cost": 2.2e-07,
"input_cost_per_token": 1.1e-06,
"litellm_provider": "azure_ai",
@@ -8921,6 +8997,7 @@
"supports_tool_choice": true
},
"azure_ai/FW-GLM-5.1": {
+ "deprecation_date": "2027-07-01",
"cache_read_input_token_cost": 2.86e-07,
"input_cost_per_token": 1.54e-06,
"litellm_provider": "azure_ai",
@@ -8987,6 +9064,7 @@
"supports_tool_choice": true
},
"azure_ai/FW-Kimi-K2.5": {
+ "deprecation_date": "2027-07-01",
"cache_read_input_token_cost": 1.1e-07,
"input_cost_per_token": 6.6e-07,
"litellm_provider": "azure_ai",
@@ -9079,6 +9157,7 @@
"supports_vision": true
},
"azure_ai/FW-MiniMax-M2.5": {
+ "deprecation_date": "2027-07-01",
"cache_read_input_token_cost": 3.3e-08,
"input_cost_per_token": 3.3e-07,
"litellm_provider": "azure_ai",
@@ -9164,6 +9243,7 @@
]
},
"azure_ai/MAI-Image-2e": {
+ "deprecation_date": "2026-08-15",
"input_cost_per_token": 5e-06,
"litellm_provider": "azure_ai",
"mode": "image_generation",
@@ -9175,6 +9255,7 @@
]
},
"azure_ai/Llama-3.2-11B-Vision-Instruct": {
+ "deprecation_date": "2026-06-13",
"input_cost_per_token": 3.7e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9188,6 +9269,7 @@
"supports_vision": true
},
"azure_ai/Llama-3.2-90B-Vision-Instruct": {
+ "deprecation_date": "2026-06-13",
"input_cost_per_token": 2.04e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9249,6 +9331,7 @@
"supports_tool_choice": true
},
"azure_ai/Meta-Llama-3.1-405B-Instruct": {
+ "deprecation_date": "2026-06-13",
"input_cost_per_token": 5.33e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9271,6 +9354,7 @@
"supports_tool_choice": true
},
"azure_ai/Meta-Llama-3.1-8B-Instruct": {
+ "deprecation_date": "2026-06-13",
"input_cost_per_token": 3e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9452,6 +9536,7 @@
"supports_reasoning": true
},
"azure_ai/mistral-document-ai-2505": {
+ "deprecation_date": "2026-07-20",
"litellm_provider": "azure_ai",
"ocr_cost_per_page": 0.003,
"mode": "ocr",
@@ -9529,6 +9614,7 @@
"output_cost_per_token": 0.0
},
"azure_ai/cohere-rerank-v3.5": {
+ "deprecation_date": "2026-05-14",
"input_cost_per_query": 0.002,
"input_cost_per_token": 0.0,
"litellm_provider": "azure_ai",
@@ -9591,6 +9677,7 @@
"supports_tool_choice": true
},
"azure_ai/deepseek-r1": {
+ "deprecation_date": "2026-08-13",
"input_cost_per_token": 1.35e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9614,6 +9701,7 @@
"supports_tool_choice": true
},
"azure_ai/deepseek-v3-0324": {
+ "deprecation_date": "2026-07-13",
"input_cost_per_token": 1.14e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9626,6 +9714,7 @@
"supports_tool_choice": true
},
"azure_ai/deepseek-v3.1": {
+ "deprecation_date": "2026-07-13",
"input_cost_per_token": 1.23e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 131072,
@@ -9639,6 +9728,7 @@
"supports_tool_choice": true
},
"azure_ai/deepseek-v4-pro": {
+ "deprecation_date": "2028-02-20",
"input_cost_per_token": 1.74e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 1000000,
@@ -9652,6 +9742,7 @@
"supports_tool_choice": true
},
"azure_ai/deepseek-v4-flash": {
+ "deprecation_date": "2028-02-20",
"input_cost_per_token": 1.9e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 1000000,
@@ -9683,6 +9774,7 @@
"supports_embedding_image_input": true
},
"azure_ai/global/grok-3": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 3e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 131072,
@@ -9697,6 +9789,7 @@
"supports_web_search": true
},
"azure_ai/global/grok-3-mini": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 2.5e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 131072,
@@ -9712,6 +9805,7 @@
"supports_web_search": true
},
"azure_ai/grok-3": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 3e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 131072,
@@ -9726,6 +9820,7 @@
"supports_web_search": true
},
"azure_ai/grok-3-mini": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 2.5e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 131072,
@@ -9773,6 +9868,7 @@
"supports_web_search": true
},
"azure_ai/grok-4-fast-non-reasoning": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 2e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
@@ -9786,6 +9882,7 @@
"supports_web_search": true
},
"azure_ai/grok-4-fast-reasoning": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 2e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
@@ -9863,6 +9960,7 @@
"supports_tool_choice": true
},
"azure_ai/kimi-k2.5": {
+ "deprecation_date": "2027-01-26",
"input_cost_per_token": 6e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 262144,
@@ -9877,6 +9975,7 @@
"supports_vision": true
},
"azure_ai/kimi-k2.6": {
+ "deprecation_date": "2027-04-16",
"input_cost_per_token": 9.5e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 262144,
@@ -10004,6 +10103,7 @@
"supports_vision": true
},
"babbage-002": {
+ "deprecation_date": "2026-09-28",
"input_cost_per_token": 4e-07,
"litellm_provider": "text-completion-openai",
"max_input_tokens": 16384,
@@ -12014,6 +12114,7 @@
]
},
"claude-haiku-4-5-20251001": {
+ "deprecation_date": "2026-10-15",
"cache_creation_input_token_cost": 1.25e-06,
"cache_creation_input_token_cost_above_1hr": 2e-06,
"cache_read_input_token_cost": 1e-07,
@@ -12037,6 +12138,7 @@
"prompt_cache_min_tokens": 4096
},
"claude-haiku-4-5": {
+ "deprecation_date": "2026-10-15",
"cache_creation_input_token_cost": 1.25e-06,
"cache_creation_input_token_cost_above_1hr": 2e-06,
"cache_read_input_token_cost": 1e-07,
@@ -12185,6 +12287,7 @@
"prompt_cache_min_tokens": 1024
},
"claude-sonnet-4-5": {
+ "deprecation_date": "2026-09-29",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05,
@@ -12218,6 +12321,7 @@
"prompt_cache_min_tokens": 1024
},
"claude-sonnet-4-5-20250929": {
+ "deprecation_date": "2026-09-29",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05,
@@ -12252,6 +12356,7 @@
"prompt_cache_min_tokens": 1024
},
"claude-sonnet-5": {
+ "deprecation_date": "2027-06-30",
"cache_creation_input_token_cost": 2.5e-06,
"cache_creation_input_token_cost_above_1hr": 4e-06,
"cache_read_input_token_cost": 2e-07,
@@ -12288,6 +12393,7 @@
"prompt_cache_min_tokens": 1024
},
"claude-sonnet-4-6": {
+ "deprecation_date": "2027-02-17",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -12434,6 +12540,7 @@
"prompt_cache_min_tokens": 1024
},
"claude-opus-4-5-20251101": {
+ "deprecation_date": "2026-11-24",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12463,6 +12570,7 @@
"prompt_cache_min_tokens": 4096
},
"claude-opus-4-5": {
+ "deprecation_date": "2026-11-24",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12492,6 +12600,7 @@
"prompt_cache_min_tokens": 4096
},
"claude-opus-4-6": {
+ "deprecation_date": "2027-02-05",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12528,6 +12637,7 @@
"prompt_cache_min_tokens": 4096
},
"claude-opus-4-6-20260205": {
+ "deprecation_date": "2027-02-05",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12564,6 +12674,7 @@
"prompt_cache_min_tokens": 4096
},
"claude-opus-4-7": {
+ "deprecation_date": "2027-04-16",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12602,6 +12713,7 @@
"prompt_cache_min_tokens": 2048
},
"claude-opus-4-7-20260416": {
+ "deprecation_date": "2027-04-16",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12640,6 +12752,7 @@
"prompt_cache_min_tokens": 2048
},
"claude-fable-5": {
+ "deprecation_date": "2027-06-09",
"cache_creation_input_token_cost": 1.25e-05,
"cache_creation_input_token_cost_above_1hr": 2e-05,
"cache_read_input_token_cost": 1e-06,
@@ -12675,6 +12788,7 @@
"prompt_cache_min_tokens": 512
},
"claude-opus-5": {
+ "deprecation_date": "2027-07-24",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12713,6 +12827,7 @@
"prompt_cache_min_tokens": 512
},
"claude-opus-4-8": {
+ "deprecation_date": "2027-05-28",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -15042,6 +15157,7 @@
"mode": "search"
},
"davinci-002": {
+ "deprecation_date": "2026-09-28",
"input_cost_per_token": 2e-06,
"litellm_provider": "text-completion-openai",
"max_input_tokens": 16384,
@@ -18594,6 +18710,7 @@
}
},
"gemini-2.5-flash": {
+ "deprecation_date": "2026-10-20",
"cache_read_input_token_cost": 3e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 3e-07,
@@ -18639,6 +18756,7 @@
"supports_image_size": false
},
"gemini-2.5-flash-image": {
+ "deprecation_date": "2026-10-02",
"cache_read_input_token_cost": 3e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 3e-07,
@@ -18683,6 +18801,7 @@
"supports_image_size": false
},
"gemini-3-pro-image": {
+ "deprecation_date": "2027-05-28",
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
@@ -18763,6 +18882,7 @@
"web_search_billing_unit": "per_query"
},
"gemini-3.1-flash-image": {
+ "deprecation_date": "2027-05-28",
"input_cost_per_image": 0.00056,
"input_cost_per_token": 5e-07,
"litellm_provider": "vertex_ai-language-models",
@@ -18887,6 +19007,7 @@
"web_search_billing_unit": "per_query"
},
"gemini-3.1-flash-lite": {
+ "deprecation_date": "2027-05-07",
"cache_read_input_token_cost": 2.5e-08,
"cache_read_input_token_cost_flex": 1.25e-08,
"cache_read_input_token_cost_priority": 4.5e-08,
@@ -18943,6 +19064,7 @@
"web_search_billing_unit": "per_query"
},
"gemini-3.5-flash-lite": {
+ "deprecation_date": "2027-07-21",
"cache_read_input_token_cost": 3e-08,
"cache_read_input_token_cost_flex": 2e-08,
"cache_read_input_token_cost_priority": 5e-08,
@@ -19032,6 +19154,7 @@
"supports_web_search": true
},
"gemini-2.5-flash-lite": {
+ "deprecation_date": "2026-10-20",
"cache_read_input_token_cost": 1e-08,
"input_cost_per_audio_token": 3e-07,
"input_cost_per_token": 1e-07,
@@ -19303,6 +19426,7 @@
"supports_image_size": false
},
"gemini-2.5-pro": {
+ "deprecation_date": "2026-10-20",
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
"cache_creation_input_token_cost_above_200k_tokens": 2.5e-07,
@@ -19617,6 +19741,7 @@
},
"vertex_ai/gemini-3.5-flash": {
"prompt_cache_min_tokens": 4096,
+ "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,
@@ -20057,6 +20182,7 @@
"web_search_billing_unit": "per_query"
},
"gemini/gemini-robotics-er-1.6-preview": {
+ "deprecation_date": "2026-08-31",
"input_cost_per_audio_token": 2e-06,
"input_cost_per_token": 1e-06,
"litellm_provider": "gemini",
@@ -20127,6 +20253,7 @@
"supports_vision": true
},
"gemini-embedding-001": {
+ "deprecation_date": "2028-05-20",
"input_cost_per_token": 1.5e-07,
"litellm_provider": "vertex_ai-embedding-models",
"max_input_tokens": 2048,
@@ -21746,6 +21873,7 @@
},
"gemini-3.5-flash": {
"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_token": 1.5e-06,
@@ -23260,6 +23388,7 @@
"supports_tool_choice": true
},
"gpt-3.5-turbo-instruct": {
+ "deprecation_date": "2026-09-28",
"input_cost_per_token": 1.5e-06,
"litellm_provider": "text-completion-openai",
"max_input_tokens": 8192,
@@ -24391,6 +24520,7 @@
"supports_pdf_input": true
},
"low/1024-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.009,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24402,6 +24532,7 @@
"supports_pdf_input": true
},
"low/1024-x-1536/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24413,6 +24544,7 @@
"supports_pdf_input": true
},
"low/1536-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24424,6 +24556,7 @@
"supports_pdf_input": true
},
"medium/1024-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.034,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24435,6 +24568,7 @@
"supports_pdf_input": true
},
"medium/1024-x-1536/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.05,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24446,6 +24580,7 @@
"supports_pdf_input": true
},
"medium/1536-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.05,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24457,6 +24592,7 @@
"supports_pdf_input": true
},
"high/1024-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.133,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24468,6 +24604,7 @@
"supports_pdf_input": true
},
"high/1024-x-1536/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.2,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24479,6 +24616,7 @@
"supports_pdf_input": true
},
"high/1536-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.2,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24490,6 +24628,7 @@
"supports_pdf_input": true
},
"standard/1024-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.009,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24501,6 +24640,7 @@
"supports_pdf_input": true
},
"standard/1024-x-1536/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24512,6 +24652,7 @@
"supports_pdf_input": true
},
"standard/1536-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24523,6 +24664,7 @@
"supports_pdf_input": true
},
"1024-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.009,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24534,6 +24676,7 @@
"supports_pdf_input": true
},
"1024-x-1536/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24545,6 +24688,7 @@
"supports_pdf_input": true
},
"1536-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -27458,18 +27602,21 @@
"output_cost_per_second": 0.0
},
"hd/1024-x-1024/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 7.629e-08,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_pixel": 0.0
},
"hd/1024-x-1792/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 6.539e-08,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_pixel": 0.0
},
"hd/1792-x-1024/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 6.539e-08,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -27516,6 +27663,7 @@
"max_output_tokens": 8192
},
"high/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.167,
"input_cost_per_pixel": 1.59263611e-07,
"litellm_provider": "openai",
@@ -27526,6 +27674,7 @@
]
},
"high/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.25,
"input_cost_per_pixel": 1.58945719e-07,
"litellm_provider": "openai",
@@ -27536,6 +27685,7 @@
]
},
"high/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.25,
"input_cost_per_pixel": 1.58945719e-07,
"litellm_provider": "openai",
@@ -28323,6 +28473,7 @@
"supports_tool_choice": true
},
"low/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.011,
"input_cost_per_pixel": 1.0490417e-08,
"litellm_provider": "openai",
@@ -28333,6 +28484,7 @@
]
},
"low/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.016,
"input_cost_per_pixel": 1.0172526e-08,
"litellm_provider": "openai",
@@ -28343,6 +28495,7 @@
]
},
"low/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.016,
"input_cost_per_pixel": 1.0172526e-08,
"litellm_provider": "openai",
@@ -28367,6 +28520,7 @@
"output_cost_per_image": 0.072
},
"medium/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.042,
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "openai",
@@ -28377,6 +28531,7 @@
]
},
"medium/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.063,
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "openai",
@@ -28387,6 +28542,7 @@
]
},
"medium/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.063,
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "openai",
@@ -28397,6 +28553,7 @@
]
},
"low/1024-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.005,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -28405,6 +28562,7 @@
]
},
"low/1024-x-1536/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.006,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -28413,6 +28571,7 @@
]
},
"low/1536-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.006,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -28421,6 +28580,7 @@
]
},
"medium/1024-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.011,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -28429,6 +28589,7 @@
]
},
"medium/1024-x-1536/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.015,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -28437,6 +28598,7 @@
]
},
"medium/1536-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.015,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -30330,6 +30492,7 @@
]
},
"multimodalembedding@001": {
+ "deprecation_date": "2027-04-01",
"input_cost_per_character": 2e-07,
"input_cost_per_image": 0.0001,
"input_cost_per_token": 8e-07,
@@ -36028,18 +36191,21 @@
"output_cost_per_image": 0.14
},
"standard/1024-x-1024/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 3.81469e-08,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_pixel": 0.0
},
"standard/1024-x-1792/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 4.359e-08,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_pixel": 0.0
},
"standard/1792-x-1024/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 4.359e-08,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -36103,6 +36269,7 @@
"source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models"
},
"text-embedding-005": {
+ "deprecation_date": "2027-04-01",
"input_cost_per_character": 2.5e-08,
"input_cost_per_token": 1e-07,
"litellm_provider": "vertex_ai-embedding-models",
@@ -36176,6 +36343,7 @@
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing"
},
"text-moderation-007": {
+ "deprecation_date": "2025-10-27",
"input_cost_per_token": 0.0,
"litellm_provider": "openai",
"max_input_tokens": 32768,
@@ -36185,6 +36353,7 @@
"output_cost_per_token": 0.0
},
"text-moderation-latest": {
+ "deprecation_date": "2025-10-27",
"input_cost_per_token": 0.0,
"litellm_provider": "openai",
"max_input_tokens": 32768,
@@ -36194,6 +36363,7 @@
"output_cost_per_token": 0.0
},
"text-moderation-stable": {
+ "deprecation_date": "2025-10-27",
"input_cost_per_token": 0.0,
"litellm_provider": "openai",
"max_input_tokens": 32768,
@@ -36203,6 +36373,7 @@
"output_cost_per_token": 0.0
},
"text-multilingual-embedding-002": {
+ "deprecation_date": "2027-04-01",
"input_cost_per_character": 2.5e-08,
"input_cost_per_token": 1e-07,
"litellm_provider": "vertex_ai-embedding-models",
@@ -38690,6 +38861,7 @@
"supports_tool_choice": true
},
"vertex_ai/claude-haiku-4-5": {
+ "deprecation_date": "2026-10-15",
"cache_creation_input_token_cost": 1.25e-06,
"cache_creation_input_token_cost_above_1hr": 2e-06,
"cache_read_input_token_cost": 1e-07,
@@ -38713,6 +38885,7 @@
"prompt_cache_min_tokens": 4096
},
"vertex_ai/claude-haiku-4-5@20251001": {
+ "deprecation_date": "2026-10-15",
"cache_creation_input_token_cost": 1.25e-06,
"cache_creation_input_token_cost_above_1hr": 2e-06,
"cache_read_input_token_cost": 1e-07,
@@ -38865,6 +39038,7 @@
"supports_vision": true
},
"vertex_ai/claude-opus-4": {
+ "deprecation_date": "2026-05-14",
"cache_creation_input_token_cost": 1.875e-05,
"cache_creation_input_token_cost_above_1hr": 3e-05,
"cache_read_input_token_cost": 1.5e-06,
@@ -38892,6 +39066,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-opus-4-1": {
+ "deprecation_date": "2026-08-05",
"cache_creation_input_token_cost": 1.875e-05,
"cache_creation_input_token_cost_above_1hr": 3e-05,
"cache_read_input_token_cost": 1.5e-06,
@@ -38910,6 +39085,7 @@
"supports_vision": true
},
"vertex_ai/claude-opus-4-1@20250805": {
+ "deprecation_date": "2026-08-05",
"cache_creation_input_token_cost": 1.875e-05,
"cache_creation_input_token_cost_above_1hr": 3e-05,
"cache_read_input_token_cost": 1.5e-06,
@@ -38928,6 +39104,7 @@
"supports_vision": true
},
"vertex_ai/claude-opus-4-5": {
+ "deprecation_date": "2026-11-24",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -38956,6 +39133,7 @@
"prompt_cache_min_tokens": 4096
},
"vertex_ai/claude-opus-4-5@20251101": {
+ "deprecation_date": "2026-11-24",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -38985,6 +39163,7 @@
"prompt_cache_min_tokens": 4096
},
"vertex_ai/claude-opus-4-6": {
+ "deprecation_date": "2027-02-05",
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
@@ -39015,6 +39194,7 @@
"prompt_cache_min_tokens": 4096
},
"vertex_ai/claude-opus-4-6@default": {
+ "deprecation_date": "2027-02-05",
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
@@ -39045,6 +39225,7 @@
"prompt_cache_min_tokens": 4096
},
"vertex_ai/claude-opus-4-7": {
+ "deprecation_date": "2027-04-16",
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
@@ -39076,6 +39257,7 @@
"prompt_cache_min_tokens": 2048
},
"vertex_ai/claude-opus-4-7@default": {
+ "deprecation_date": "2027-04-16",
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
@@ -39107,6 +39289,7 @@
"prompt_cache_min_tokens": 2048
},
"vertex_ai/claude-fable-5": {
+ "deprecation_date": "2027-06-08",
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 1.25e-05,
"cache_creation_input_token_cost_above_1hr": 2e-05,
@@ -39138,6 +39321,7 @@
"supports_max_reasoning_effort": true
},
"vertex_ai/claude-fable-5@default": {
+ "deprecation_date": "2027-06-08",
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 1.25e-05,
"cache_creation_input_token_cost_above_1hr": 2e-05,
@@ -39169,6 +39353,7 @@
"supports_max_reasoning_effort": true
},
"vertex_ai/claude-opus-5": {
+ "deprecation_date": "2027-01-24",
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
@@ -39201,6 +39386,7 @@
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-5@default": {
+ "deprecation_date": "2027-01-24",
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
@@ -39233,6 +39419,7 @@
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-4-8": {
+ "deprecation_date": "2027-05-28",
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
@@ -39265,6 +39452,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-opus-4-8@default": {
+ "deprecation_date": "2027-05-28",
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
@@ -39297,6 +39485,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-sonnet-4-5": {
+ "deprecation_date": "2026-09-29",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -39325,6 +39514,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-sonnet-5": {
+ "deprecation_date": "2026-12-24",
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 2.5e-06,
"cache_creation_input_token_cost_above_1hr": 4e-06,
@@ -39387,6 +39577,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-sonnet-4-5@20250929": {
+ "deprecation_date": "2026-09-29",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -39416,6 +39607,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-opus-4@20250514": {
+ "deprecation_date": "2026-05-14",
"cache_creation_input_token_cost": 1.875e-05,
"cache_creation_input_token_cost_above_1hr": 3e-05,
"cache_read_input_token_cost": 1.5e-06,
@@ -39443,6 +39635,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-sonnet-4": {
+ "deprecation_date": "2026-05-14",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -39474,6 +39667,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-sonnet-4@20250514": {
+ "deprecation_date": "2026-05-14",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -39638,6 +39832,7 @@
"supports_tool_choice": true
},
"vertex_ai/gemini-2.5-flash-image": {
+ "deprecation_date": "2026-10-02",
"cache_read_input_token_cost": 3e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 3e-07,
@@ -39683,6 +39878,7 @@
"supports_image_size": false
},
"vertex_ai/gemini-3-pro-image": {
+ "deprecation_date": "2027-05-28",
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
@@ -39715,6 +39911,7 @@
"source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image"
},
"vertex_ai/gemini-3.1-flash-image": {
+ "deprecation_date": "2027-05-28",
"input_cost_per_image": 0.00056,
"input_cost_per_token": 5e-07,
"litellm_provider": "vertex_ai-language-models",
@@ -39791,6 +39988,7 @@
"web_search_billing_unit": "per_query"
},
"vertex_ai/gemini-3.1-flash-lite": {
+ "deprecation_date": "2027-05-07",
"cache_read_input_token_cost": 2.5e-08,
"cache_read_input_token_cost_flex": 1.25e-08,
"cache_read_input_token_cost_priority": 4.5e-08,
@@ -39847,6 +40045,7 @@
"web_search_billing_unit": "per_query"
},
"vertex_ai/gemini-3.5-flash-lite": {
+ "deprecation_date": "2027-07-21",
"cache_read_input_token_cost": 3e-08,
"cache_read_input_token_cost_flex": 2e-08,
"cache_read_input_token_cost_priority": 5e-08,
@@ -40564,6 +40763,7 @@
"supports_tool_choice": true
},
"vertex_ai/veo-2.0-generate-001": {
+ "deprecation_date": "2026-06-30",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,
@@ -40578,6 +40778,7 @@
]
},
"vertex_ai/veo-3.0-fast-generate-001": {
+ "deprecation_date": "2026-06-30",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,
@@ -40592,6 +40793,7 @@
]
},
"vertex_ai/veo-3.0-generate-001": {
+ "deprecation_date": "2026-06-30",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,
@@ -40634,6 +40836,7 @@
]
},
"vertex_ai/veo-3.1-generate-001": {
+ "deprecation_date": "2026-11-17",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,
@@ -40648,6 +40851,7 @@
]
},
"vertex_ai/veo-3.1-fast-generate-001": {
+ "deprecation_date": "2026-11-17",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,
@@ -47029,6 +47233,7 @@
}
},
"vertex_ai/claude-sonnet-5@default": {
+ "deprecation_date": "2026-12-24",
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 2.5e-06,
"cache_creation_input_token_cost_above_1hr": 4e-06,
diff --git a/litellm/proxy/_experimental/mcp_server/exceptions.py b/litellm/proxy/_experimental/mcp_server/exceptions.py
index 8c704c0fe93..a1b3b167a4a 100644
--- a/litellm/proxy/_experimental/mcp_server/exceptions.py
+++ b/litellm/proxy/_experimental/mcp_server/exceptions.py
@@ -75,6 +75,24 @@ class MCPUpstreamAuthError(Exception):
)
+class MCPOpenApiUpstreamError(Exception):
+ """An OpenAPI-backed MCP tool's upstream answered with a non-2xx that is not a 401.
+
+ Carries the status only. The upstream's response body is deliberately dropped rather than served
+ as tool content: it crosses a trust boundary and may hold prose, urls, or an error document that
+ reads as data, which is how these failures came to be reported as successful tool output. This
+ matches ``outcome_wire_value``'s contract for listing faults, category and status and nothing
+ else. A 401 is raised as ``MCPUpstreamAuthError`` instead, so the caller learns to
+ re-authenticate; every other status stays here, mirroring the regular MCP path where a 403
+ deliberately does not produce a challenge.
+ """
+
+ def __init__(self, status_code: int, server_name: str) -> None:
+ self.status_code = status_code
+ self.server_name = server_name
+ super().__init__(f"upstream returned HTTP {status_code}")
+
+
class MCPToolResultError(Exception):
"""An MCP tool call completed with ``isError=True`` in its result.
diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
index 65855df89f6..26a6f8d1251 100644
--- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
+++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
@@ -2330,7 +2330,15 @@ class MCPServerManager:
input_schema = build_input_schema(resolved_operation)
# Create tool function with headers using imported function
- tool_func = create_tool_function(path, method, resolved_operation, base_url, headers=headers)
+ tool_func = create_tool_function(
+ path,
+ method,
+ resolved_operation,
+ base_url,
+ headers=headers,
+ server_label=server.name or server.server_name or server.alias or server.server_id,
+ relays_upstream_auth=server.is_client_forwarded_token,
+ )
tool_func.__name__ = prefixed_tool_name
tool_func.__doc__ = description
@@ -4979,6 +4987,12 @@ class MCPServerManager:
return result
+ except MCPUpstreamAuthError:
+ # The caller must re-authenticate upstream, so this keeps its type all the way to the
+ # renderers: the streamable path turns it into an isError result naming the status, and
+ # the REST path relays a real 401 with the upstream's WWW-Authenticate. Flattening it
+ # into the generic message below would lose both.
+ raise
except Exception as e:
error_msg = f"Error calling OpenAPI tool {tool_name}: {e}"
verbose_logger.error(error_msg)
diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
index eb78aaeca0b..083a98cdd36 100644
--- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
+++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py
@@ -15,6 +15,12 @@ from urllib.parse import quote
import httpx
from typing_extensions import ReadOnly, Required
+from litellm.llms.custom_httpx.http_handler import MaskedHTTPStatusError
+from litellm.proxy._experimental.mcp_server.exceptions import (
+ MCPOpenApiUpstreamError,
+ MCPUpstreamAuthError,
+)
+
# Tool names emitted from OpenAPI specs must work across all major LLM providers.
# OpenAI/Anthropic/Bedrock all enforce a character class roughly equivalent to
# ^[a-zA-Z0-9_-]+$ on tool names. Many specs (notably GitHub's REST API) use
@@ -392,12 +398,40 @@ def _merge_openapi_tool_request_headers(
return effective_headers
+def _raise_for_upstream_failure(
+ response: httpx.Response,
+ upstream: str,
+ relays_upstream_auth: bool,
+) -> None:
+ """Turn a non-2xx upstream response into the right typed failure, or return for a 2xx.
+
+ Both call sites feed this: ``get`` hands back the response for a 4xx, while post/put/patch/delete
+ raise ``MaskedHTTPStatusError`` from inside the HTTP handler, so without one classifier the
+ non-GET tools would keep serving an error body as tool output.
+
+ Only the client-forwarded modes carry the caller's own upstream token, so only they can act on a
+ 401 by re-authenticating; ``_call_regular_mcp_tool`` gates its re-auth signal the same way. Every
+ other status carries the code alone, never the upstream's body, which crosses a trust boundary.
+ """
+ if response.status_code < 400:
+ return
+ if response.status_code == 401 and relays_upstream_auth:
+ raise MCPUpstreamAuthError(
+ status_code=response.status_code,
+ www_authenticate=response.headers.get("www-authenticate"),
+ server_name=upstream,
+ )
+ raise MCPOpenApiUpstreamError(response.status_code, upstream)
+
+
def create_tool_function(
path: str,
method: str,
operation: _OpenAPIOperation,
base_url: str,
headers: dict[str, str] | None = None,
+ server_label: str | None = None,
+ relays_upstream_auth: bool = False,
):
"""Create a tool function for an OpenAPI operation.
@@ -477,20 +511,26 @@ def create_tool_function(
json_body = {"data": body_value}
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
+ upstream: Final = server_label or f"{original_method.upper()} {path}"
- if original_method == "get":
- response = await client.get(url, params=params, headers=effective_headers)
- elif original_method == "post":
- response = await client.post(url, params=params, json=json_body, headers=effective_headers)
- elif original_method == "put":
- response = await client.put(url, params=params, json=json_body, headers=effective_headers)
- elif original_method == "delete":
- response = await client.delete(url, params=params, headers=effective_headers)
- elif original_method == "patch":
- response = await client.patch(url, params=params, json=json_body, headers=effective_headers)
- else:
- return f"Unsupported HTTP method: {original_method}"
+ try:
+ if original_method == "get":
+ response = await client.get(url, params=params, headers=effective_headers)
+ elif original_method == "post":
+ response = await client.post(url, params=params, json=json_body, headers=effective_headers)
+ elif original_method == "put":
+ response = await client.put(url, params=params, json=json_body, headers=effective_headers)
+ elif original_method == "delete":
+ response = await client.delete(url, params=params, headers=effective_headers)
+ elif original_method == "patch":
+ response = await client.patch(url, params=params, json=json_body, headers=effective_headers)
+ else:
+ return f"Unsupported HTTP method: {original_method}"
+ except MaskedHTTPStatusError as e:
+ _raise_for_upstream_failure(e.response, upstream, relays_upstream_auth)
+ raise
+ _raise_for_upstream_failure(response, upstream, relays_upstream_auth)
return response.text
return tool_function
diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index 8d69d84e492..0dc85c0318c 100644
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -407,8 +407,6 @@ if MCP_AVAILABLE:
StreamableHTTPSessionManager = None
from mcp.types import (
CallToolResult,
- EmbeddedResource,
- ImageContent,
ListToolsResult,
Prompt,
TextContent,
@@ -2861,12 +2859,11 @@ if MCP_AVAILABLE:
_extra_token: Final = _request_extra_headers.set(forwarded_headers)
_resolved_token: Final = _request_resolved_auth_headers.set(resolved_auth_headers)
try:
- local_content = await _handle_local_mcp_tool(name, arguments)
+ response = await _handle_local_mcp_tool(name, arguments)
finally:
_request_auth_header.reset(_auth_token)
_request_extra_headers.reset(_extra_token)
_request_resolved_auth_headers.reset(_resolved_token)
- response = CallToolResult(content=local_content, isError=False)
# Try managed MCP server tool (the name is bare; the prefix boundary was
# already resolved above against this server's registered prefixes)
@@ -2940,8 +2937,7 @@ if MCP_AVAILABLE:
if "arguments" in hook_result:
arguments = hook_result["arguments"] # pyright: ignore[reportAny] # hook returns untyped args
- local_content = await _handle_local_mcp_tool(original_tool_name, arguments)
- response = CallToolResult(content=local_content, isError=False)
+ response = await _handle_local_mcp_tool(original_tool_name, arguments)
return await _run_post_mcp_call_guardrails(
result=response,
@@ -3319,11 +3315,18 @@ if MCP_AVAILABLE:
verbose_logger.debug("CALL TOOL RESULT: %s", call_tool_result)
return call_tool_result
- async def _handle_local_mcp_tool(
- name: str, arguments: dict[str, object]
- ) -> list[TextContent | ImageContent | EmbeddedResource]:
- """
- Handle tool execution for local registry tools
+ async def _handle_local_mcp_tool(name: str, arguments: dict[str, object]) -> CallToolResult:
+ """Execute a local-registry tool and report whether it succeeded.
+
+ Returns the result rather than bare content because the verdict is part of it: the content
+ alone cannot say whether the handler failed, so callers used to stamp isError=False on every
+ outcome and an upstream rejection was served as tool output.
+
+ A failure is reported as ``isError=True`` here rather than raised, because the REST surface
+ turns an unrecognized exception into a 500 and an upstream 403 or 429 is not a gateway crash.
+ ``MCPUpstreamAuthError`` is the exception: it propagates so the caller is told to
+ re-authenticate, which both renderers already know how to say.
+
Note: Local tools don't use prefixes, so we use the original name
"""
import inspect
@@ -3333,15 +3336,16 @@ if MCP_AVAILABLE:
raise HTTPException(status_code=404, detail=f"Tool '{name}' not found")
try:
- # Check if handler is async or sync
if inspect.iscoroutinefunction(tool.handler):
result = await tool.handler(**arguments)
else:
result = tool.handler(**arguments)
- return [TextContent(text=str(result), type="text")]
+ except MCPUpstreamAuthError:
+ raise
except Exception as e:
verbose_logger.exception("Error executing local tool %s: %s", name, e)
- return [TextContent(text=f"Error: {e}", type="text")]
+ return CallToolResult(content=[TextContent(text=f"Error: {e}", type="text")], isError=True)
+ return CallToolResult(content=[TextContent(text=str(result), type="text")], isError=False)
def _get_mcp_servers_in_path(path: str) -> list[str] | None:
"""
diff --git a/litellm/proxy/management_endpoints/auto_router_endpoints.py b/litellm/proxy/management_endpoints/auto_router_endpoints.py
index d8b7414f32c..0112ad1f6ed 100644
--- a/litellm/proxy/management_endpoints/auto_router_endpoints.py
+++ b/litellm/proxy/management_endpoints/auto_router_endpoints.py
@@ -6,10 +6,13 @@ POST /auto_router/test_routing - Route one prompt through an unsaved complexity-
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
+from itertools import groupby
+from operator import attrgetter
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final, Protocol
+from uuid import uuid4
-from pydantic import BaseModel, TypeAdapter
+from pydantic import BaseModel, ConfigDict, TypeAdapter, field_validator
from litellm._logging import verbose_proxy_logger
from litellm.exceptions import BudgetExceededError
@@ -41,6 +44,8 @@ from litellm.types.management_endpoints.auto_router_endpoints import (
AutoRouterRoutingTestRequest,
AutoRouterRoutingTestResponse,
RequestComplexityRouterConfig,
+ ShadowEvalDirection,
+ ShadowEvalJobKeyResponse,
ShadowEvalJobResponse,
ShadowEvalResult,
ShadowEvalSlice,
@@ -89,17 +94,9 @@ class _ShadowEvalJobRow(Protocol):
class _ShadowEvalJobTable(Protocol):
- async def find_unique(self, *, where: Mapping[str, object]) -> _ShadowEvalJobRow | None: ...
+ async def find_many(self, *, where: Mapping[str, object]) -> Sequence[_ShadowEvalJobRow]: ...
- async def find_first(self, *, where: Mapping[str, object]) -> _ShadowEvalJobRow | None: ...
-
- async def find_many(
- self, *, where: Mapping[str, object], order: Mapping[str, str], take: int
- ) -> Sequence[_ShadowEvalJobRow]: ...
-
- async def create(self, data: Mapping[str, object]) -> _ShadowEvalJobRow: ...
-
- async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> _ShadowEvalJobRow | None: ...
+ async def create_many(self, data: Sequence[Mapping[str, object]]) -> int: ...
class _ShadowEvalAttemptRow(Protocol):
@@ -606,18 +603,19 @@ _ATTEMPT_AGG_SELECT: Final = """
COUNT(*) FILTER (WHERE outcome = 'tie')::int AS ties,
AVG(confidence)::float AS avg_confidence
FROM "LiteLLM_ShadowEvalAttempt"
-WHERE job_id = $1 AND outcome != 'error'
+WHERE job_id = ANY($1::text[]) AND outcome != 'error'
GROUP BY 1
"""
_ATTEMPT_AGG_BY_TIER_SQL: Final = "SELECT COALESCE(tier, 'UNCLASSIFIED') AS grp," + _ATTEMPT_AGG_SELECT
_ATTEMPT_AGG_BY_MODEL_SQL: Final = "SELECT COALESCE(real_model, 'unknown') AS grp," + _ATTEMPT_AGG_SELECT
+_ATTEMPT_AGG_BY_LEG_SQL: Final = "SELECT job_id AS grp," + _ATTEMPT_AGG_SELECT
_SWEEP_FINISHED_JOBS_SQL: Final = """
-UPDATE "LiteLLM_ShadowEvalJob" j SET stopped_at = NOW()
-WHERE j.api_key_id = $1 AND j.stopped_at IS NULL
+UPDATE "LiteLLM_ShadowEvalJob" j SET stopped_at = (NOW() AT TIME ZONE 'utc')
+WHERE j.api_key_id = ANY($1::text[]) AND j.stopped_at IS NULL
AND (
- j.ends_at <= NOW()
+ j.ends_at <= (NOW() AT TIME ZONE 'utc')
OR (SELECT COUNT(*) FROM "LiteLLM_ShadowEvalAttempt" a WHERE a.job_id = j.id) >= j.max_turns
)
"""
@@ -628,7 +626,52 @@ SELECT
COUNT(*) FILTER (WHERE outcome = 'error')::int AS error_count,
COALESCE(SUM(judge_cost), 0)::float AS judge_spend
FROM "LiteLLM_ShadowEvalAttempt"
-WHERE job_id = $1
+WHERE job_id = ANY($1::text[])
+"""
+
+_ATTEMPT_COUNTS_SQL: Final = """
+SELECT a.job_id, COUNT(*)::int AS attempt_count
+FROM "LiteLLM_ShadowEvalAttempt" a
+JOIN "LiteLLM_ShadowEvalJob" j ON j.id = a.job_id
+WHERE a.job_id = ANY($1::text[]) AND (j.stopped_at IS NULL OR a.created_at <= j.stopped_at)
+GROUP BY a.job_id
+"""
+
+_STOP_JOB_SQL: Final = """
+UPDATE "LiteLLM_ShadowEvalJob"
+SET stopped_by = $2, stopped_at = COALESCE(stopped_at, $3::timestamp)
+WHERE group_id = $1 AND stopped_by IS NULL
+ AND ends_at > (NOW() AT TIME ZONE 'utc')
+ AND EXISTS (
+ SELECT 1 FROM "LiteLLM_ShadowEvalJob" k
+ WHERE k.group_id = $1 AND k.stopped_at IS NULL
+ AND (SELECT COUNT(*) FROM "LiteLLM_ShadowEvalAttempt" a WHERE a.job_id = k.id) < k.max_turns
+ )
+"""
+
+
+class _AttemptCountRow(BaseModel):
+ job_id: str
+ attempt_count: int
+
+
+_ATTEMPT_COUNT_ROWS: Final = TypeAdapter(list[_AttemptCountRow])
+
+
+_LIST_LEGS_SQL: Final = """
+SELECT * FROM "LiteLLM_ShadowEvalJob"
+WHERE group_id IN (
+ SELECT group_id FROM "LiteLLM_ShadowEvalJob"
+ GROUP BY group_id ORDER BY MAX(created_at) DESC LIMIT $1::int
+)
+"""
+
+_LIST_LEGS_BY_KEY_SQL: Final = """
+SELECT * FROM "LiteLLM_ShadowEvalJob"
+WHERE group_id IN (
+ SELECT group_id FROM "LiteLLM_ShadowEvalJob" WHERE api_key_id = $2
+ GROUP BY group_id ORDER BY MAX(created_at) DESC LIMIT $1::int
+)
"""
@@ -659,18 +702,98 @@ def _slices(rows: Sequence[_AttemptAggRow]) -> tuple[ShadowEvalSlice, ...]:
)
+class _LegRow(BaseModel):
+ """One LiteLLM_ShadowEvalJob row, validated off the untyped prisma record. A row is
+ one key's leg of a job; the legs of a job share group_id and identical config, written
+ together by one create_many. The API's job id is the group id, so leg ids never leave
+ the server (attempts reference them internally)."""
+
+ model_config = ConfigDict(from_attributes=True)
+
+ id: str
+ group_id: str
+ api_key_id: str
+ router_name: str
+ direction: ShadowEvalDirection
+ baseline_model: str | None = None
+ judge_model: str
+ shadow_percentage: float
+ max_turns: int
+ created_at: datetime
+ ends_at: datetime
+ stopped_at: datetime | None = None
+ stopped_by: str | None = None
+
+ @field_validator("created_at", "ends_at", "stopped_at")
+ @classmethod
+ def _as_aware_utc(cls, value: datetime | None) -> datetime | None:
+ """The columns store naive UTC wall time (prisma's convention); prisma reads hand
+ back aware datetimes while raw SQL reads hand back naive ones, so this boundary
+ makes every read aware UTC before anything compares or serializes them."""
+ if value is None or value.tzinfo is not None:
+ return value
+ return value.replace(tzinfo=timezone.utc)
+
+
+_LEG_ROWS: Final = TypeAdapter(list[_LegRow])
+
+
+async def _leg_attempt_counts(prisma_client: "PrismaClient", legs: Sequence[_LegRow]) -> Mapping[str, int]:
+ """Each leg's attempt count by leg id, judged and errored alike, in one grouped read.
+ It is the same count the sampler budgets against max_turns, so the derived status
+ flips to completed exactly when sampling actually ends. A stamped leg's count freezes
+ at its stopped_at: in-flight attempts that land after the stamp are excluded, so they
+ can never reclassify a leg that was stopped under budget as budget-spent."""
+ if not legs:
+ return MappingProxyType({})
+ rows: Final = _ATTEMPT_COUNT_ROWS.validate_python(
+ await _query_raw(prisma_client, _ATTEMPT_COUNTS_SQL, [leg.id for leg in legs]) # mutable-ok: query param
+ or ()
+ )
+ return MappingProxyType({row.job_id: row.attempt_count for row in rows})
+
+
+def _group_response(group_id: str, legs: Sequence[_LegRow], attempt_counts: Mapping[str, int]) -> ShadowEvalJobResponse:
+ """The one constructor of a job response: the caller names the group and passes that
+ group's legs. Config is read off the first leg because every leg carries the same copy,
+ written by one create_many. No caller may serialize a raw row (that would leak a leg id
+ as the job id)."""
+ first: Final = legs[0]
+ return ShadowEvalJobResponse(
+ job_id=group_id,
+ keys=tuple(
+ ShadowEvalJobKeyResponse(
+ api_key_id=leg.api_key_id,
+ max_turns=leg.max_turns,
+ stopped_at=leg.stopped_at,
+ attempt_count=attempt_counts.get(leg.id, 0),
+ )
+ for leg in sorted(legs, key=lambda leg: leg.api_key_id)
+ ),
+ router_name=first.router_name,
+ direction=first.direction,
+ baseline_model=first.baseline_model,
+ judge_model=first.judge_model,
+ shadow_percentage=first.shadow_percentage,
+ created_at=first.created_at,
+ ends_at=first.ends_at,
+ stopped_by=next((leg.stopped_by for leg in legs if leg.stopped_by is not None), None),
+ )
+
+
_NO_KEY_LABELS: Final[tuple[str | None, str | None]] = (None, None)
async def _with_key_labels(
prisma_client: "PrismaClient", responses: Sequence[ShadowEvalJobResponse]
) -> tuple[ShadowEvalJobResponse, ...]:
- """Resolve each job's key hash to the key's alias and masked name in one batched read,
+ """Resolve every scoped key's hash to its alias and masked name in one batched read,
so the UI can say whose traffic a job shadows. Deleted keys resolve to None."""
if not responses:
return ()
+ tokens: Final = sorted(frozenset(key.api_key_id for response in responses for key in response.keys))
key_rows: Final = await _verification_tokens(prisma_client).find_many(
- where={"token": {"in": sorted({response.api_key_id for response in responses})}} # mutable-ok: Prisma filter
+ where={"token": {"in": tokens}} # mutable-ok: Prisma filter
)
labels: Final[Mapping[str, tuple[str | None, str | None]]] = {
row.token: (row.key_alias, row.key_name) for row in key_rows or ()
@@ -678,32 +801,50 @@ async def _with_key_labels(
return tuple(
response.model_copy(
update={ # mutable-ok: pydantic update payload
- "key_alias": labels.get(response.api_key_id, _NO_KEY_LABELS)[0],
- "key_name": labels.get(response.api_key_id, _NO_KEY_LABELS)[1],
+ "keys": tuple(
+ key.model_copy(
+ update={ # mutable-ok: pydantic update payload
+ "key_alias": labels.get(key.api_key_id, _NO_KEY_LABELS)[0],
+ "key_name": labels.get(key.api_key_id, _NO_KEY_LABELS)[1],
+ }
+ )
+ for key in response.keys
+ )
}
)
for response in responses
)
-async def _shadow_eval_results(prisma_client: "PrismaClient", job_id: str) -> ShadowEvalResult | None:
- """Both stratifications of one job's verdicts. Tier answers "where does the router do
- well"; the model stratification groups by whichever model served the real arm, so it
- answers "which of the models this key uses today would the router beat" forward, and
- "for the turns the router sent to X, did X beat the baseline" in reverse. Reads are
- bounded by the job's own attempts (<= max_turns) via the job_id index."""
+async def _shadow_eval_results(prisma_client: "PrismaClient", legs: Sequence[_LegRow]) -> ShadowEvalResult | None:
+ """All three stratifications of one job's verdicts. Tier answers "where does the router
+ do well"; the model stratification groups by whichever model served the real arm, so it
+ answers "which of the models these keys use today would the router beat" forward, and
+ "for the turns the router sent to X, did X beat the baseline" in reverse; key answers
+ "which key's traffic does the router suit". Reads are bounded by the job's own attempts
+ (<= the sum of its keys' max_turns) via the job_id index."""
+ leg_ids: Final = [leg.id for leg in legs] # mutable-ok: query param
by_tier: Final = _ATTEMPT_AGG_ROWS.validate_python(
- await _query_raw(prisma_client, _ATTEMPT_AGG_BY_TIER_SQL, job_id) or ()
+ await _query_raw(prisma_client, _ATTEMPT_AGG_BY_TIER_SQL, leg_ids) or ()
)
if not by_tier:
return None
by_model: Final = _ATTEMPT_AGG_ROWS.validate_python(
- await _query_raw(prisma_client, _ATTEMPT_AGG_BY_MODEL_SQL, job_id) or ()
+ await _query_raw(prisma_client, _ATTEMPT_AGG_BY_MODEL_SQL, leg_ids) or ()
+ )
+ key_by_leg: Final = MappingProxyType({leg.id: leg.api_key_id for leg in legs})
+ by_leg: Final = _ATTEMPT_AGG_ROWS.validate_python(
+ await _query_raw(prisma_client, _ATTEMPT_AGG_BY_LEG_SQL, leg_ids) or ()
+ )
+ by_key: Final = tuple(
+ row.model_copy(update={"grp": key_by_leg[row.grp]}) # mutable-ok: pydantic update payload
+ for row in by_leg
)
total_turns: Final = sum(r.turn_count for r in by_tier)
return ShadowEvalResult(
by_tier=_slices(by_tier),
by_current_model=_slices(by_model),
+ by_key=_slices(by_key),
overall_shadow_win_rate_pct=_pct_of(sum(r.shadow_wins for r in by_tier), total_turns),
overall_tie_rate_pct=_pct_of(sum(r.ties for r in by_tier), total_turns),
)
@@ -721,20 +862,21 @@ async def start_shadow_eval(
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> ShadowEvalJobResponse:
"""
- Start a shadow eval: duplicate a sampled slice of a key's live traffic against a second
- arm, judge the two responses blind, and stratify win rates by tier and by the model that
- served the real arm.
+ Start a shadow eval: duplicate a sampled slice of one or more keys' live traffic against
+ a second arm, judge the two responses blind, and stratify win rates by tier, by the model
+ that served the real arm, and by key.
- A forward job answers whether the key should adopt router_name: it samples the requests
+ A forward job answers whether the keys should adopt router_name: it samples the requests
the router did not serve and duplicates them through it. A reverse job answers whether a
key already on the router still gains from it: it samples the requests the router did
serve and duplicates them against baseline_model. A key can hold one active job per
direction, so both questions can run at once.
- Shadow responses are never served to users. The job samples until it has judged
- max_turns turns, reaches the end of its window, or is stopped; sampling changes
- propagate to pods within about 10 seconds. Shadow and judge calls bill to the
- shadowed key but are excluded from request counts and auto-router adoption metrics.
+ Shadow responses are never served to users. Each key samples until it has judged
+ max_turns turns of its own traffic, the job's window ends, or the job is stopped, so one
+ key running out of budget does not end sampling for the others; sampling changes
+ propagate to pods within about 10 seconds. Shadow and judge calls bill to the shadowed
+ key but are excluded from request counts and auto-router adoption metrics.
"""
from litellm.proxy.proxy_server import llm_router, prisma_client
@@ -746,48 +888,58 @@ async def start_shadow_eval(
_validate_plain_model(llm_router, data.judge_model, "judge_model")
if data.baseline_model is not None:
_validate_plain_model(llm_router, data.baseline_model, "baseline_model")
- key_row: Final = await _verification_tokens(prisma_client).find_unique(
- where={"token": data.api_key_id} # mutable-ok: Prisma filter
+ token_rows: Final = await _verification_tokens(prisma_client).find_many(
+ where={"token": {"in": list(data.api_key_ids)}} # mutable-ok: Prisma filter
)
- if key_row is None:
+ unknown: Final = tuple(sorted(frozenset(data.api_key_ids) - frozenset(row.token for row in token_rows or ())))
+ if unknown:
raise HTTPException(
status_code=400,
detail=(
- f"api_key_id '{data.api_key_id}' is not a key on this proxy; pass the key's token hash, "
+ f"api_key_ids not on this proxy: {', '.join(unknown)}; pass each key's token hash, "
"the value the key list and key info endpoints report"
),
)
- # A job that expired or exhausted its turn budget stopped sampling on its own, but
- # still holds its slot in the per-key, per-direction partial unique index until
- # stamped; free it so a new eval can start. Sweeping both directions is deliberate.
- await prisma_client.db.execute_raw(_SWEEP_FINISHED_JOBS_SQL, data.api_key_id)
- active: Final = await _shadow_eval_jobs(prisma_client).find_first(
+ # A job whose window passed or whose turn budget ran out stopped sampling on its own,
+ # but its legs still hold their slots in the per-key, per-direction partial unique index
+ # until stamped; free them so a new eval can start. Sweeping both directions is deliberate.
+ requested: Final = list(data.api_key_ids) # mutable-ok: query param
+ await prisma_client.db.execute_raw(_SWEEP_FINISHED_JOBS_SQL, requested)
+ claimed: Final = await _shadow_eval_jobs(prisma_client).find_many(
where={ # mutable-ok: Prisma filter
- "api_key_id": data.api_key_id,
+ "api_key_id": {"in": requested}, # mutable-ok: Prisma filter
"direction": data.direction,
"stopped_at": None,
},
)
- if active is not None:
+ if claimed:
raise HTTPException(
status_code=409,
- detail=f"Key already has an active {data.direction} shadow eval job ({active.id}). Stop it first.",
+ detail=(
+ f"Already in an active {data.direction} shadow eval job: "
+ + ", ".join(sorted(f"{row.api_key_id} (job {row.group_id})" for row in claimed))
+ + ". Stop it first."
+ ),
)
now: Final = datetime.now(timezone.utc)
+ group_id: Final = str(uuid4())
+ ends_at: Final = now + timedelta(days=data.duration_days)
+ shared_config: Final = { # mutable-ok: Prisma payload
+ "group_id": group_id,
+ "router_name": data.router_name,
+ "direction": data.direction,
+ "baseline_model": data.baseline_model,
+ "judge_model": data.judge_model,
+ "shadow_percentage": data.shadow_percentage,
+ "max_turns": data.max_turns,
+ "created_by": user_api_key_dict.user_id,
+ "created_at": now,
+ "ends_at": ends_at,
+ }
try:
- job: Final = await _shadow_eval_jobs(prisma_client).create(
- data={ # mutable-ok: Prisma payload
- "api_key_id": data.api_key_id,
- "router_name": data.router_name,
- "direction": data.direction,
- "baseline_model": data.baseline_model,
- "judge_model": data.judge_model,
- "shadow_percentage": data.shadow_percentage,
- "max_turns": data.max_turns,
- "created_by": user_api_key_dict.user_id,
- "ends_at": now + timedelta(days=data.duration_days),
- }
+ await _shadow_eval_jobs(prisma_client).create_many(
+ data=[{**shared_config, "api_key_id": key} for key in data.api_key_ids] # mutable-ok: Prisma payload
)
except Exception as e:
if not _is_unique_violation(e):
@@ -795,11 +947,28 @@ async def start_shadow_eval(
raise HTTPException(
status_code=409,
detail=(
- f"Key already has an active {data.direction} shadow eval job (started concurrently). Stop it first."
+ f"A requested key was claimed by another {data.direction} shadow eval job concurrently. Stop it first."
),
) from e
- return ShadowEvalJobResponse.model_validate(job, from_attributes=True).model_copy(
- update={"key_alias": key_row.key_alias, "key_name": key_row.key_name} # mutable-ok: pydantic update payload
+ labels: Final = MappingProxyType({row.token: row for row in token_rows})
+ return ShadowEvalJobResponse(
+ job_id=group_id,
+ keys=tuple(
+ ShadowEvalJobKeyResponse(
+ api_key_id=api_key_id,
+ max_turns=data.max_turns,
+ key_alias=labels[api_key_id].key_alias,
+ key_name=labels[api_key_id].key_name,
+ )
+ for api_key_id in sorted(data.api_key_ids)
+ ),
+ router_name=data.router_name,
+ direction=data.direction,
+ baseline_model=data.baseline_model,
+ judge_model=data.judge_model,
+ shadow_percentage=data.shadow_percentage,
+ created_at=now,
+ ends_at=ends_at,
)
@@ -811,23 +980,38 @@ async def start_shadow_eval(
)
async def list_shadow_eval_jobs(
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
- api_key_id: Annotated[str | None, Query(description="Filter to jobs shadowing this key")] = None,
+ api_key_id: Annotated[
+ str | None, Query(description="Filter to jobs that shadow this key, alone or alongside others")
+ ] = None,
limit: Annotated[int, Query(ge=1, le=200, description="Newest jobs to return")] = 50,
) -> tuple[ShadowEvalJobResponse, ...]:
- """List shadow eval jobs, newest first. Counts and results ride the detail endpoint only."""
+ """List shadow eval jobs, newest first, each key with its attempt count so status is
+ accurate. Judged counts, spend, and results ride the detail endpoint only."""
from litellm.proxy.proxy_server import prisma_client
_require_admin_viewer(user_api_key_dict, "view shadow evals")
if prisma_client is None:
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
- records: Final = await _shadow_eval_jobs(prisma_client).find_many(
- where={"api_key_id": api_key_id} if api_key_id else {}, # mutable-ok: Prisma filter
- order={"created_at": "desc"}, # mutable-ok: Prisma order
- take=limit,
+ legs: Final = _LEG_ROWS.validate_python(
+ (
+ await _query_raw(prisma_client, _LIST_LEGS_BY_KEY_SQL, limit, api_key_id)
+ if api_key_id
+ else await _query_raw(prisma_client, _LIST_LEGS_SQL, limit)
+ )
+ or ()
)
+ by_group: Final[Mapping[str, tuple[_LegRow, ...]]] = MappingProxyType(
+ {
+ group_id: tuple(group)
+ for group_id, group in groupby(sorted(legs, key=attrgetter("group_id")), key=attrgetter("group_id"))
+ }
+ )
+ newest_first: Final = sorted(
+ by_group, key=lambda group_id: max(leg.created_at for leg in by_group[group_id]), reverse=True
+ )
+ counts: Final = await _leg_attempt_counts(prisma_client, legs)
return await _with_key_labels(
- prisma_client,
- tuple(ShadowEvalJobResponse.model_validate(record, from_attributes=True) for record in records or ()),
+ prisma_client, tuple(_group_response(group_id, by_group[group_id], counts) for group_id in newest_first)
)
@@ -847,20 +1031,24 @@ async def get_shadow_eval_job(
_require_admin_viewer(user_api_key_dict, "view shadow evals")
if prisma_client is None:
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
- record: Final = await _shadow_eval_jobs(prisma_client).find_unique(
- where={"id": job_id} # mutable-ok: Prisma filter
+ legs: Final = _LEG_ROWS.validate_python(
+ await _shadow_eval_jobs(prisma_client).find_many(
+ where={"group_id": job_id} # mutable-ok: Prisma filter
+ )
+ or ()
)
- if record is None:
+ if not legs:
raise HTTPException(status_code=404, detail=f"No shadow eval job {job_id}")
+ leg_ids: Final = [leg.id for leg in legs] # mutable-ok: query param
totals: Final = _ATTEMPT_TOTALS_ROWS.validate_python(
- await _query_raw(prisma_client, _ATTEMPT_TOTALS_SQL, job_id) or ()
+ await _query_raw(prisma_client, _ATTEMPT_TOTALS_SQL, leg_ids) or ()
)
latest_error: Final = await _shadow_eval_attempts(prisma_client).find_first(
- where={"job_id": job_id, "outcome": "error"}, # mutable-ok: Prisma filter
+ where={"job_id": {"in": leg_ids}, "outcome": "error"}, # mutable-ok: Prisma filter
order={"created_at": "desc"}, # mutable-ok: Prisma order
)
labeled: Final = await _with_key_labels(
- prisma_client, (ShadowEvalJobResponse.model_validate(record, from_attributes=True),)
+ prisma_client, (_group_response(job_id, legs, await _leg_attempt_counts(prisma_client, legs)),)
)
return labeled[0].model_copy(
update={ # mutable-ok: pydantic update payload
@@ -868,7 +1056,7 @@ async def get_shadow_eval_job(
"error_count": totals[0].error_count if totals else 0,
"judge_spend": round(totals[0].judge_spend, 6) if totals else 0.0,
"last_error": latest_error.error if latest_error else None,
- "results": await _shadow_eval_results(prisma_client, job_id),
+ "results": await _shadow_eval_results(prisma_client, legs),
}
)
@@ -883,25 +1071,33 @@ async def stop_shadow_eval_job(
job_id: str,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> ShadowEvalJobResponse:
- """Stop an active shadow eval job. Attempts are kept; sampling halts within ~10s."""
+ """Stop an active shadow eval job, every key it scopes at once. Attempts are kept;
+ sampling halts within ~10s. Keys that already stopped on their own budget keep the
+ stopped_at they earned. The statement is the whole state machine: it claims the job
+ only while a leg still samples inside the window with no stop recorded, so a racing
+ operator, a same-instant budget spend, and a repeat stop all read the same 400 with
+ the status the job actually holds."""
from litellm.proxy.proxy_server import prisma_client
_require_admin_writer(user_api_key_dict, "stop a shadow eval")
if prisma_client is None:
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
- record: Final = await _shadow_eval_jobs(prisma_client).find_unique(
- where={"id": job_id} # mutable-ok: Prisma filter
+ stamp: Final = datetime.now(timezone.utc)
+ operator: Final = user_api_key_dict.user_id or "operator"
+ claimed: Final = await prisma_client.db.execute_raw(
+ _STOP_JOB_SQL, job_id, operator, stamp.replace(tzinfo=None).isoformat()
)
- if record is None:
+ legs: Final = _LEG_ROWS.validate_python(
+ await _shadow_eval_jobs(prisma_client).find_many(
+ where={"group_id": job_id} # mutable-ok: Prisma filter
+ )
+ or ()
+ )
+ if not legs:
raise HTTPException(status_code=404, detail=f"No shadow eval job {job_id}")
- current: Final = ShadowEvalJobResponse.model_validate(record, from_attributes=True)
- if current.status != "running":
+ counts: Final = await _leg_attempt_counts(prisma_client, legs)
+ current: Final = _group_response(job_id, legs, counts)
+ if claimed == 0:
raise HTTPException(status_code=400, detail=f"Job {job_id} is already {current.status}")
- updated: Final = await _shadow_eval_jobs(prisma_client).update(
- where={"id": job_id}, # mutable-ok: Prisma filter
- data={"stopped_at": datetime.now(timezone.utc)}, # mutable-ok: Prisma payload
- )
- labeled: Final = await _with_key_labels(
- prisma_client, (ShadowEvalJobResponse.model_validate(updated, from_attributes=True),)
- )
+ labeled: Final = await _with_key_labels(prisma_client, (current,))
return labeled[0]
diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py
index 67a836b8c92..ab343cbde2d 100644
--- a/litellm/proxy/management_endpoints/key_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/key_management_endpoints.py
@@ -1416,6 +1416,11 @@ async def _check_team_key_limits(
)
+_INHERITED_MODEL_SENTINELS: Final = frozenset(
+ {SpecialModelNames.all_team_models.value, SpecialModelNames.all_proxy_models.value}
+)
+
+
async def _check_project_key_limits(
project_id: str,
data: GenerateKeyRequest | UpdateKeyRequest,
@@ -1425,7 +1430,8 @@ async def _check_project_key_limits(
"""
Validate that key's models and budget respect its project's limits.
- - Key models must be a subset of project models
+ - Key models must be a subset of project models, except the all-team-models / all-proxy-models
+ sentinels, which inherit a parent scope and are narrowed by the project at request time
- Key max_budget must be <= project max_budget
"""
project_obj: Final = await get_project_object(
@@ -1443,7 +1449,7 @@ async def _check_project_key_limits(
# Validate key models are a subset of project models
if data.models and len(project_obj.models) > 0:
for m in data.models:
- if m not in project_obj.models:
+ if m not in project_obj.models and m not in _INHERITED_MODEL_SENTINELS:
raise HTTPException(
status_code=400,
detail={
diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma
index 52fb447157b..f79e2bb0c18 100644
--- a/litellm/proxy/schema.prisma
+++ b/litellm/proxy/schema.prisma
@@ -1467,28 +1467,38 @@ model LiteLLM_AutoRouterSession {
@@index([last_turn_at], map: "idx_autorouter_session_last_turn")
}
-// Shadow eval: evaluation of an auto-router against a key's live traffic, in either
-// direction. forward duplicates the requests the key did not route through the router
-// through it, answering whether the key should adopt it; reverse duplicates the requests
-// the router did serve against a fixed baseline model, answering whether a key already on
-// it still benefits. Either way a sampled slice runs in a detached task and an LLM judge
-// compares real vs shadow responses blind. The job row is immutable config plus
-// stopped_at; every count, status, and spend figure is derived from the append-only
-// attempt rows, so nothing can disagree across pods or stop races.
+// Shadow eval: evaluation of an auto-router against one or more keys' live traffic, in
+// either direction. forward duplicates the requests the keys did not route through the
+// router through it, answering whether they should adopt it; reverse duplicates the
+// requests the router did serve against a fixed baseline model, answering whether a key
+// already on it still benefits. Either way a sampled slice runs in a detached task and an
+// LLM judge compares real vs shadow responses blind. Each row is ONE key's leg of a job:
+// immutable config plus that key's own turn budget and stop state, so one key exhausting
+// its budget never ends a sibling's sampling. A job is the set of legs sharing group_id
+// (the id the API reports), written together by one atomic create_many with identical
+// config; single-key jobs predating group_id were backfilled group_id = id. "One active
+// job per (key, direction)" is a partial unique index on (api_key_id, direction) WHERE
+// stopped_at IS NULL, expressed only in the migration because schema.prisma cannot state
+// partial indexes; it is what makes a concurrent start on another pod race-safe rather
+// than read-then-create. Every count, status, and spend figure is derived from the
+// append-only attempt rows, so nothing can disagree across pods or stop races.
model LiteLLM_ShadowEvalJob {
id String @id @default(cuid())
- api_key_id String // hashed virtual key whose traffic is shadowed
+ group_id String // legs of one job share this; the API's job id
+ api_key_id String // hashed virtual key whose traffic this leg shadows
router_name String // the auto-router under evaluation, in either direction
direction String @default("forward") // forward | reverse
baseline_model String? // reverse only: the fixed model the router is judged against
judge_model String
shadow_percentage Float
- max_turns Int // sample budget: judge at most this many turns
+ max_turns Int // this key's sample budget: judge at most this many turns
created_at DateTime @default(now())
created_by String?
ends_at DateTime
stopped_at DateTime?
+ stopped_by String? // operator who stopped it early; null when it ended on its own
+ @@index([group_id])
@@index([api_key_id])
@@index([created_at])
}
diff --git a/litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py b/litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py
index efdbda47fdc..25de9f6d065 100644
--- a/litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py
+++ b/litellm/proxy/spend_tracking/ptu_flat_cost_rollup.py
@@ -17,6 +17,7 @@ import json
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
+from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from litellm._logging import verbose_proxy_logger
@@ -110,17 +111,56 @@ def _public_model_name(row: object, model_info: Mapping[str, object]) -> str:
def _decode_model_info(raw: object) -> "Mapping[str, object] | None":
- """A deployment's model_info as a dict, decoding a JSON string, else None."""
+ """A deployment's model_info as a mapping, decoding a JSON string, else None.
+
+ Valid JSON that is not an object decodes to a list or a scalar, which every caller
+ would then read fields off, so it is rejected here rather than raised past them.
+ """
if isinstance(raw, str):
try:
- return json.loads(raw)
+ decoded: Final = json.loads(raw)
except (TypeError, ValueError):
return None
- if isinstance(raw, dict):
+ return decoded if isinstance(decoded, dict) else None
+ if isinstance(raw, Mapping):
return raw
return None
+@dataclass(frozen=True, slots=True)
+class _PTUDeployment:
+ """A deployment in the shape ``_parse_ptu_model`` reads, whatever declared it.
+
+ A ``LiteLLM_ProxyModelTable`` row already has it. A router entry does not: its id
+ lives in ``model_info.id`` rather than on the entry itself.
+ """
+
+ model_id: str
+ model_name: str
+ model_info: Mapping[str, object]
+
+
+def _router_deployment(deployment: Mapping[str, object]) -> _PTUDeployment | None:
+ """A router ``model_list`` entry in the shape the parser reads, else None.
+
+ An id is required rather than defaulted because it keys the sentinel row: every
+ deployment without one would collapse onto a single row per team and only the last
+ would be billed. The mapping is copied because the router rewrites entries in place
+ while the rollup runs.
+ """
+ model_info: Final = _decode_model_info(deployment.get("model_info"))
+ if model_info is None:
+ return None
+ model_id: Final = model_info.get("id")
+ if not isinstance(model_id, str) or not model_id:
+ return None
+ return _PTUDeployment(
+ model_id=model_id,
+ model_name=str(deployment.get("model_name") or ""),
+ model_info=MappingProxyType(dict(model_info)),
+ )
+
+
def _parse_ptu_model(row: object) -> PTUModel | None:
"""Return a PTUModel when the deployment carries valid manual PTU config, else None.
diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py
index d16063b9bd4..0573f8acf18 100644
--- a/litellm/router_strategy/complexity_router/complexity_router.py
+++ b/litellm/router_strategy/complexity_router/complexity_router.py
@@ -1020,13 +1020,14 @@ class ComplexityRouter(CustomLogger):
weights: Final = self.config.dimension_weights
weighted_score: Final = sum(d.score * weights.get(d.name, 0) for d in dimensions)
- # Check for reasoning override (2+ reasoning markers)
+ boundaries: Final = self._effective_tier_boundaries()
+ scored_above_simple: Final = weighted_score >= boundaries["simple_medium"]
+
# Reuse match count from _score_keyword_match to avoid scanning twice
- if reasoning_match_count >= 2:
+ if reasoning_match_count >= 2 and scored_above_simple:
return ComplexityTier.REASONING, weighted_score, tuple(signals), "reasoning_override"
# Map score to tier
- boundaries: Final = self._effective_tier_boundaries()
if weighted_score < boundaries["simple_medium"]:
tier = ComplexityTier.SIMPLE
elif weighted_score < boundaries["medium_complex"]:
diff --git a/litellm/types/management_endpoints/auto_router_endpoints.py b/litellm/types/management_endpoints/auto_router_endpoints.py
index 9461297feca..63c93e0f268 100644
--- a/litellm/types/management_endpoints/auto_router_endpoints.py
+++ b/litellm/types/management_endpoints/auto_router_endpoints.py
@@ -6,7 +6,7 @@ from collections.abc import Mapping
from datetime import datetime, timezone
from typing import Final, Literal, TypeAlias
-from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
+from pydantic import BaseModel, Field, computed_field, field_validator, model_validator
from litellm.router_strategy.complexity_router.config import ComplexityRouterConfig
from litellm.types.utils import StandardLoggingRoutingDecision
@@ -155,13 +155,17 @@ DEFAULT_SHADOW_EVAL_JUDGE_MODEL: Final[str] = "anthropic/claude-sonnet-5"
class StartShadowEvalRequest(BaseModel):
- """Start duplicating a key's traffic for blind comparison against an auto-router."""
+ """Start duplicating one or more keys' traffic for blind comparison against an auto-router."""
- api_key_id: str = Field(
+ api_key_ids: tuple[str, ...] = Field(
+ min_length=1,
+ max_length=100,
description=(
- "The hashed virtual key whose traffic will be shadowed. Shadow evaluation runs ONLY on this "
- "key's traffic; requests made with any other key are not sampled."
- )
+ "The hashed virtual keys whose traffic will be shadowed. Shadow evaluation runs ONLY on these "
+ "keys' traffic; requests made with any other key are not sampled. Each key carries its own "
+ "max_turns budget, so one key exhausting its budget leaves the others sampling. At most 100 "
+ "keys per job, which also bounds every read the job's endpoints make."
+ ),
)
router_name: str = Field(description="The auto-router under evaluation, in either direction")
direction: ShadowEvalDirection = Field(
@@ -204,8 +208,9 @@ class StartShadowEvalRequest(BaseModel):
ge=1,
le=2000,
description=(
- "Sample budget: the job judges at most this many turns, then completes. This is also the spend "
- "bound; expected judge cost is roughly max_turns times one judge call"
+ "Per-key sample budget: the job judges at most this many turns of EACH scoped key's traffic, "
+ "so a job over N keys judges at most N times max_turns turns. This is also the spend bound; "
+ "expected judge cost is roughly that turn ceiling times one judge call"
),
)
@@ -214,6 +219,12 @@ class StartShadowEvalRequest(BaseModel):
def _round_percentage(cls, value: float) -> float:
return round(value, 2)
+ @field_validator("api_key_ids")
+ @classmethod
+ def _dedupe_keys(cls, value: tuple[str, ...]) -> tuple[str, ...]:
+ """A key named twice would collide with itself on the one-active-per-(key, direction) index."""
+ return tuple(dict.fromkeys(value))
+
@model_validator(mode="after")
def _baseline_model_matches_direction(self) -> "StartShadowEvalRequest":
if self.direction == "reverse" and self.baseline_model is None:
@@ -251,24 +262,46 @@ class ShadowEvalResult(BaseModel):
by_tier: tuple[ShadowEvalSlice, ...]
by_current_model: tuple[ShadowEvalSlice, ...] = Field(
description=(
- "Sliced by the model that served the real arm: the key's incumbent models in forward mode, "
+ "Sliced by the model that served the real arm: the keys' incumbent models in forward mode, "
"and in reverse the models the router itself picked"
)
)
+ by_key: tuple[ShadowEvalSlice, ...] = Field(
+ description=(
+ "One slice per scoped key that has judged verdicts, grouped on the raw key hash. Keys the job "
+ "scopes but has not judged a turn for yet are absent rather than reported as zero"
+ ),
+ )
overall_shadow_win_rate_pct: float
overall_tie_rate_pct: float
-class ShadowEvalJobResponse(BaseModel):
- """A shadow-eval job. Validates directly from the prisma record (job_id reads the
- row's id); status is derived from stopped_at and ends_at, never stored, so no writer
- anywhere can produce an inconsistent one. Aggregate fields are populated by the
- detail endpoint only and stay None on list responses."""
+class ShadowEvalJobKeyResponse(BaseModel):
+ """One key a job shadows, with its own budget and stop state."""
- model_config = ConfigDict(from_attributes=True, populate_by_name=True)
+ api_key_id: str = Field(description="The hashed virtual key whose traffic this entry scopes")
+ max_turns: int = Field(description="This key's own sample budget, independent of its siblings'")
+ stopped_at: datetime | None = Field(
+ default=None,
+ description=(
+ "When this key's slot was stamped free, whether its own budget ran out, the window closed, "
+ "or an operator stopped the job; status is derived, so a spent budget reads completed even "
+ "while this is still unset"
+ ),
+ )
+ attempt_count: int | None = Field(
+ default=None,
+ description=(
+ "This key's sampled attempts so far, judged and errored alike, the same count the sampler "
+ "budgets against max_turns; populated on list and detail responses. Frozen at stopped_at "
+ "once the key is stamped, so in-flight attempts landing after a stop never reclassify it"
+ ),
+ )
+
+ @property
+ def budget_spent(self) -> bool:
+ return self.attempt_count is not None and self.attempt_count >= self.max_turns
- job_id: str = Field(validation_alias=AliasChoices("id", "job_id"))
- api_key_id: str = Field(description="The hashed virtual key whose traffic this job evaluates, and only that key's")
key_alias: str | None = Field(
default=None,
description="Alias of the shadowed key, resolved from the key row at read time; None when unset or deleted",
@@ -277,15 +310,34 @@ class ShadowEvalJobResponse(BaseModel):
default=None,
description="Masked display name (sk-...) of the shadowed key, resolved at read time like key_alias",
)
+
+
+class ShadowEvalJobResponse(BaseModel):
+ """A shadow-eval job over one or more keys, each with its own budget and stop state;
+ status is derived from stopped_by, the keys' stop and budget state, and ends_at,
+ never stored, so no writer anywhere can produce an inconsistent one. Aggregate
+ fields are populated by the detail endpoint only and stay None on list responses."""
+
+ job_id: str
+ keys: tuple[ShadowEvalJobKeyResponse, ...] = Field(
+ min_length=1,
+ description="The keys whose traffic this job evaluates, and only those keys', each with its own budget",
+ )
router_name: str
direction: ShadowEvalDirection = "forward"
baseline_model: str | None = None
judge_model: str
shadow_percentage: float
- max_turns: int
created_at: datetime
ends_at: datetime
- stopped_at: datetime | None = None
+ stopped_by: str | None = Field(
+ default=None,
+ description=(
+ "The operator who stopped the job early, recorded by the stop endpoint; 'unknown' backfilled "
+ "by migration for jobs that displayed stopped when the column arrived; None when the job "
+ "ended on its own. Its presence is what makes a job read stopped rather than completed"
+ ),
+ )
judged_count: int | None = Field(default=None, description="Verdicts recorded; detail endpoint only")
error_count: int | None = Field(default=None, description="Sampled attempts that errored; detail endpoint only")
@@ -296,12 +348,19 @@ class ShadowEvalJobResponse(BaseModel):
@computed_field
@property
def status(self) -> ShadowEvalStatus:
- """A job whose window has passed reads completed even if a later sweep stamped
- stopped_at; stopped means sampling ended before the window did."""
+ """Three recorded facts, no history-guessing: a stop is stopped_by (the migration
+ backfills it for every job that displayed stopped when the column arrived, so the
+ pre-column population is closed), completion is the window passing or every key
+ spending its budget, and anything else is running. The all-keys-stamped fallback
+ covers only stops written by pre-column pods during a rolling deploy."""
+ if self.stopped_by is not None:
+ return "stopped"
if datetime.now(timezone.utc) >= (
self.ends_at if self.ends_at.tzinfo else self.ends_at.replace(tzinfo=timezone.utc)
):
return "completed"
- if self.stopped_at is not None:
+ if all(key.budget_spent for key in self.keys):
+ return "completed"
+ if all(key.stopped_at is not None for key in self.keys):
return "stopped"
return "running"
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 8c07ca35443..1ffd0a00a22 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -54,6 +54,7 @@
"output_cost_per_image": 0.04
},
"1024-x-1024/dall-e-2": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 1.9e-08,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -67,6 +68,7 @@
"output_cost_per_image": 0.08
},
"256-x-256/dall-e-2": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 2.4414e-07,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -80,6 +82,7 @@
"output_cost_per_image": 0.018
},
"512-x-512/dall-e-2": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 6.86e-08,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -2887,6 +2890,7 @@
"supports_function_calling": true
},
"azure_ai/claude-haiku-4-5": {
+ "deprecation_date": "2026-10-19",
"cache_creation_input_token_cost": 1.25e-06,
"cache_creation_input_token_cost_above_1hr": 2e-06,
"cache_read_input_token_cost": 1e-07,
@@ -2908,6 +2912,7 @@
"supports_vision": true
},
"azure_ai/claude-opus-4-5": {
+ "deprecation_date": "2026-10-19",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -2930,6 +2935,7 @@
"supports_output_config": true
},
"azure_ai/claude-opus-4-6": {
+ "deprecation_date": "2027-02-02",
"supports_adaptive_thinking": true,
"input_cost_per_token": 5e-06,
"output_cost_per_token": 2.5e-05,
@@ -2959,6 +2965,7 @@
"supports_max_reasoning_effort": true
},
"azure_ai/claude-opus-4-7": {
+ "deprecation_date": "2027-04-06",
"supports_adaptive_thinking": true,
"input_cost_per_token": 5e-06,
"output_cost_per_token": 2.5e-05,
@@ -3083,6 +3090,7 @@
"supports_max_reasoning_effort": true
},
"azure_ai/claude-opus-4-1": {
+ "deprecation_date": "2026-08-05",
"cache_creation_input_token_cost": 1.875e-05,
"cache_creation_input_token_cost_above_1hr": 3e-05,
"cache_read_input_token_cost": 1.5e-06,
@@ -3104,6 +3112,7 @@
"supports_vision": true
},
"azure_ai/claude-sonnet-4-5": {
+ "deprecation_date": "2026-10-19",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -3156,6 +3165,7 @@
"supports_max_reasoning_effort": true
},
"azure_ai/claude-sonnet-4-6": {
+ "deprecation_date": "2027-02-10",
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
@@ -3226,6 +3236,7 @@
"supports_tool_choice": true
},
"azure_ai/gpt-5.5": {
+ "deprecation_date": "2027-10-26",
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
"cache_read_input_token_cost_priority": 1e-06,
@@ -3318,6 +3329,7 @@
"supports_minimal_reasoning_effort": false
},
"azure_ai/gpt-5.4": {
+ "deprecation_date": "2027-09-02",
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
"cache_read_input_token_cost_priority": 5e-07,
@@ -3364,6 +3376,7 @@
"supports_minimal_reasoning_effort": true
},
"azure_ai/gpt-5.4-2026-03-05": {
+ "deprecation_date": "2027-09-02",
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
"cache_read_input_token_cost_priority": 5e-07,
@@ -3410,6 +3423,7 @@
"supports_minimal_reasoning_effort": true
},
"azure_ai/gpt-5.4-pro": {
+ "deprecation_date": "2027-09-07",
"cache_read_input_token_cost": 3e-06,
"cache_read_input_token_cost_above_272k_tokens": 6e-06,
"cache_read_input_token_cost_priority": 6e-06,
@@ -3455,6 +3469,7 @@
"supports_minimal_reasoning_effort": true
},
"azure_ai/gpt-5.4-pro-2026-03-05": {
+ "deprecation_date": "2027-09-07",
"cache_read_input_token_cost": 3e-06,
"cache_read_input_token_cost_above_272k_tokens": 6e-06,
"cache_read_input_token_cost_priority": 6e-06,
@@ -3500,6 +3515,7 @@
"supports_minimal_reasoning_effort": true
},
"azure_ai/gpt-5.4-mini": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 7.5e-08,
"cache_read_input_token_cost_priority": 1.5e-07,
"input_cost_per_token": 7.5e-07,
@@ -3540,6 +3556,7 @@
"supports_minimal_reasoning_effort": false
},
"azure_ai/gpt-5.4-mini-2026-03-17": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 7.5e-08,
"cache_read_input_token_cost_priority": 1.5e-07,
"input_cost_per_token": 7.5e-07,
@@ -3580,6 +3597,7 @@
"supports_minimal_reasoning_effort": false
},
"azure_ai/gpt-5.4-nano": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 2e-08,
"cache_read_input_token_cost_priority": 4e-08,
"input_cost_per_token": 2e-07,
@@ -3620,6 +3638,7 @@
"supports_minimal_reasoning_effort": false
},
"azure_ai/gpt-5.4-nano-2026-03-17": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 2e-08,
"cache_read_input_token_cost_priority": 4e-08,
"input_cost_per_token": 2e-07,
@@ -3849,6 +3868,7 @@
"supports_vision": true
},
"azure/eu/gpt-5.1": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.38e-06,
"litellm_provider": "azure",
@@ -3918,6 +3938,7 @@
"supports_none_reasoning_effort": true
},
"azure/eu/gpt-5.1-codex": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.38e-06,
"litellm_provider": "azure",
@@ -3948,6 +3969,7 @@
"supports_vision": true
},
"azure/eu/gpt-5.1-codex-mini": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 2.8e-08,
"input_cost_per_token": 2.75e-07,
"litellm_provider": "azure",
@@ -4107,6 +4129,7 @@
"supports_vision": true
},
"azure/global-standard/gpt-4o-mini": {
+ "deprecation_date": "2027-04-14",
"input_cost_per_token": 1.5e-07,
"litellm_provider": "azure",
"max_input_tokens": 128000,
@@ -4155,6 +4178,7 @@
"supports_vision": true
},
"azure/global/gpt-5.1": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "azure",
@@ -4224,6 +4248,7 @@
"supports_none_reasoning_effort": true
},
"azure/global/gpt-5.1-codex": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "azure",
@@ -4254,6 +4279,7 @@
"supports_vision": true
},
"azure/global/gpt-5.1-codex-mini": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 2.5e-08,
"input_cost_per_token": 2.5e-07,
"litellm_provider": "azure",
@@ -4492,6 +4518,7 @@
"supports_vision": true
},
"azure/gpt-4.1": {
+ "deprecation_date": "2027-04-14",
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
@@ -4559,6 +4586,7 @@
"supports_web_search": false
},
"azure/gpt-4.1-mini": {
+ "deprecation_date": "2027-04-14",
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 4e-07,
"input_cost_per_token_batches": 2e-07,
@@ -4626,6 +4654,7 @@
"supports_web_search": false
},
"azure/gpt-4.1-nano": {
+ "deprecation_date": "2026-10-14",
"cache_read_input_token_cost": 2.5e-08,
"input_cost_per_token": 1e-07,
"input_cost_per_token_batches": 5e-08,
@@ -4902,6 +4931,7 @@
"supports_vision": false
},
"azure/gpt-4o-mini": {
+ "deprecation_date": "2027-04-14",
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 1.65e-07,
"litellm_provider": "azure",
@@ -5344,6 +5374,7 @@
"supports_vision": true
},
"azure/gpt-5": {
+ "deprecation_date": "2027-02-09",
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "azure",
@@ -5507,6 +5538,7 @@
"supports_vision": true
},
"azure/gpt-5-mini": {
+ "deprecation_date": "2027-02-09",
"cache_read_input_token_cost": 2.5e-08,
"input_cost_per_token": 2.5e-07,
"litellm_provider": "azure",
@@ -5572,6 +5604,7 @@
"supports_vision": true
},
"azure/gpt-5-nano": {
+ "deprecation_date": "2027-02-09",
"cache_read_input_token_cost": 5e-09,
"input_cost_per_token": 5e-08,
"litellm_provider": "azure",
@@ -5667,6 +5700,7 @@
"supports_vision": true
},
"azure/gpt-5.1": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "azure",
@@ -5736,6 +5770,7 @@
"supports_none_reasoning_effort": true
},
"azure/gpt-5.1-codex": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "azure",
@@ -5797,6 +5832,7 @@
"supports_vision": true
},
"azure/gpt-5.1-codex-mini": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 2.5e-08,
"input_cost_per_token": 2.5e-07,
"litellm_provider": "azure",
@@ -5827,6 +5863,7 @@
"supports_vision": true
},
"azure/gpt-5.2": {
+ "deprecation_date": "2027-06-08",
"cache_read_input_token_cost": 1.75e-07,
"input_cost_per_token": 1.75e-06,
"litellm_provider": "azure",
@@ -6136,6 +6173,7 @@
"supports_web_search": true
},
"azure/gpt-5.4": {
+ "deprecation_date": "2027-09-02",
"cache_read_input_token_cost": 2.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 5e-07,
"cache_read_input_token_cost_priority": 5e-07,
@@ -6180,6 +6218,7 @@
"supports_minimal_reasoning_effort": true
},
"azure/us/gpt-5.4": {
+ "deprecation_date": "2027-09-02",
"cache_read_input_token_cost": 2.8e-07,
"cache_read_input_token_cost_priority": 5.5e-07,
"input_cost_per_token": 2.75e-06,
@@ -6218,6 +6257,7 @@
"supports_minimal_reasoning_effort": true
},
"azure/eu/gpt-5.4": {
+ "deprecation_date": "2027-09-02",
"cache_read_input_token_cost": 2.8e-07,
"cache_read_input_token_cost_priority": 5.5e-07,
"input_cost_per_token": 2.75e-06,
@@ -6379,6 +6419,7 @@
"supports_minimal_reasoning_effort": true
},
"azure/gpt-5.4-pro": {
+ "deprecation_date": "2027-09-07",
"cache_read_input_token_cost": 3e-06,
"cache_read_input_token_cost_above_272k_tokens": 6e-06,
"input_cost_per_token": 3e-05,
@@ -7045,6 +7086,7 @@
"supports_minimal_reasoning_effort": false
},
"azure/gpt-5.5": {
+ "deprecation_date": "2027-10-26",
"cache_read_input_token_cost": 5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
"cache_read_input_token_cost_priority": 1e-06,
@@ -7095,6 +7137,7 @@
"supports_minimal_reasoning_effort": false
},
"azure/us/gpt-5.5": {
+ "deprecation_date": "2027-10-26",
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.38e-06,
@@ -7142,6 +7185,7 @@
"supports_minimal_reasoning_effort": false
},
"azure/eu/gpt-5.5": {
+ "deprecation_date": "2027-10-26",
"cache_read_input_token_cost": 5.5e-07,
"cache_read_input_token_cost_above_272k_tokens": 1.1e-06,
"cache_read_input_token_cost_priority": 1.38e-06,
@@ -7408,6 +7452,7 @@
"supports_web_search": true
},
"azure/gpt-5.4-mini": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "azure",
@@ -7489,6 +7534,7 @@
"supports_xhigh_reasoning_effort": true
},
"azure/gpt-5.4-nano": {
+ "deprecation_date": "2027-09-21",
"cache_read_input_token_cost": 2e-08,
"input_cost_per_token": 2e-07,
"litellm_provider": "azure",
@@ -7601,6 +7647,7 @@
"output_cost_per_token": 0.0
},
"azure/high/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.59263611e-07,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7610,6 +7657,7 @@
]
},
"azure/high/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.58945719e-07,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7619,6 +7667,7 @@
]
},
"azure/high/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.58945719e-07,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7628,6 +7677,7 @@
]
},
"azure/low/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.0490417e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7637,6 +7687,7 @@
]
},
"azure/low/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.0172526e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7646,6 +7697,7 @@
]
},
"azure/low/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 1.0172526e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7655,6 +7707,7 @@
]
},
"azure/medium/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7664,6 +7717,7 @@
]
},
"azure/medium/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7673,6 +7727,7 @@
]
},
"azure/medium/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7695,6 +7750,7 @@
]
},
"azure/gpt-image-1.5": {
+ "deprecation_date": "2027-06-16",
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_image_token": 8e-06,
@@ -7720,6 +7776,7 @@
]
},
"azure/gpt-image-2": {
+ "deprecation_date": "2027-10-21",
"cache_read_input_token_cost": 1.25e-06,
"input_cost_per_token": 5e-06,
"input_cost_per_image_token": 8e-06,
@@ -7751,6 +7808,7 @@
"supports_pdf_input": true
},
"azure/low/1024-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 2.0751953125e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7760,6 +7818,7 @@
]
},
"azure/low/1024-x-1536/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 2.0751953125e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7769,6 +7828,7 @@
]
},
"azure/low/1536-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 2.0345052083e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7778,6 +7838,7 @@
]
},
"azure/medium/1024-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 8.056640625e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7787,6 +7848,7 @@
]
},
"azure/medium/1024-x-1536/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 8.056640625e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7796,6 +7858,7 @@
]
},
"azure/medium/1536-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 7.9752604167e-09,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7805,6 +7868,7 @@
]
},
"azure/high/1024-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 3.173828125e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7814,6 +7878,7 @@
]
},
"azure/high/1024-x-1536/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 3.173828125e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7823,6 +7888,7 @@
]
},
"azure/high/1536-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2027-04-07",
"input_cost_per_pixel": 3.1575520833e-08,
"litellm_provider": "azure",
"mode": "image_generation",
@@ -7850,6 +7916,7 @@
"supports_function_calling": true
},
"azure/o1": {
+ "deprecation_date": "2026-10-21",
"cache_read_input_token_cost": 7.5e-06,
"input_cost_per_token": 1.5e-05,
"litellm_provider": "azure",
@@ -7944,6 +8011,7 @@
"supports_vision": false
},
"azure/o3": {
+ "deprecation_date": "2026-10-21",
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "azure",
@@ -8041,6 +8109,7 @@
"supports_web_search": true
},
"azure/o3-mini": {
+ "deprecation_date": "2026-10-01",
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 1.1e-06,
"litellm_provider": "azure",
@@ -8071,6 +8140,7 @@
"supports_vision": false
},
"azure/o3-pro": {
+ "deprecation_date": "2026-12-17",
"input_cost_per_token": 2e-05,
"input_cost_per_token_batches": 1e-05,
"litellm_provider": "azure",
@@ -8132,6 +8202,7 @@
"supports_vision": true
},
"azure/o4-mini": {
+ "deprecation_date": "2026-10-16",
"cache_read_input_token_cost": 2.75e-07,
"input_cost_per_token": 1.1e-06,
"litellm_provider": "azure",
@@ -8580,6 +8651,7 @@
"supports_vision": true
},
"azure/us/gpt-5.1": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.38e-06,
"litellm_provider": "azure",
@@ -8649,6 +8721,7 @@
"supports_none_reasoning_effort": true
},
"azure/us/gpt-5.1-codex": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 1.4e-07,
"input_cost_per_token": 1.38e-06,
"litellm_provider": "azure",
@@ -8679,6 +8752,7 @@
"supports_vision": true
},
"azure/us/gpt-5.1-codex-mini": {
+ "deprecation_date": "2027-05-15",
"cache_read_input_token_cost": 2.8e-08,
"input_cost_per_token": 2.75e-07,
"litellm_provider": "azure",
@@ -8876,6 +8950,7 @@
]
},
"azure_ai/FW-DeepSeek-V3.2": {
+ "deprecation_date": "2027-07-01",
"cache_read_input_token_cost": 3.1e-07,
"input_cost_per_token": 6.2e-07,
"litellm_provider": "azure_ai",
@@ -8906,6 +8981,7 @@
"supports_tool_choice": true
},
"azure_ai/FW-GLM-5": {
+ "deprecation_date": "2027-07-01",
"cache_read_input_token_cost": 2.2e-07,
"input_cost_per_token": 1.1e-06,
"litellm_provider": "azure_ai",
@@ -8921,6 +8997,7 @@
"supports_tool_choice": true
},
"azure_ai/FW-GLM-5.1": {
+ "deprecation_date": "2027-07-01",
"cache_read_input_token_cost": 2.86e-07,
"input_cost_per_token": 1.54e-06,
"litellm_provider": "azure_ai",
@@ -8987,6 +9064,7 @@
"supports_tool_choice": true
},
"azure_ai/FW-Kimi-K2.5": {
+ "deprecation_date": "2027-07-01",
"cache_read_input_token_cost": 1.1e-07,
"input_cost_per_token": 6.6e-07,
"litellm_provider": "azure_ai",
@@ -9079,6 +9157,7 @@
"supports_vision": true
},
"azure_ai/FW-MiniMax-M2.5": {
+ "deprecation_date": "2027-07-01",
"cache_read_input_token_cost": 3.3e-08,
"input_cost_per_token": 3.3e-07,
"litellm_provider": "azure_ai",
@@ -9164,6 +9243,7 @@
]
},
"azure_ai/MAI-Image-2e": {
+ "deprecation_date": "2026-08-15",
"input_cost_per_token": 5e-06,
"litellm_provider": "azure_ai",
"mode": "image_generation",
@@ -9175,6 +9255,7 @@
]
},
"azure_ai/Llama-3.2-11B-Vision-Instruct": {
+ "deprecation_date": "2026-06-13",
"input_cost_per_token": 3.7e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9188,6 +9269,7 @@
"supports_vision": true
},
"azure_ai/Llama-3.2-90B-Vision-Instruct": {
+ "deprecation_date": "2026-06-13",
"input_cost_per_token": 2.04e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9249,6 +9331,7 @@
"supports_tool_choice": true
},
"azure_ai/Meta-Llama-3.1-405B-Instruct": {
+ "deprecation_date": "2026-06-13",
"input_cost_per_token": 5.33e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9271,6 +9354,7 @@
"supports_tool_choice": true
},
"azure_ai/Meta-Llama-3.1-8B-Instruct": {
+ "deprecation_date": "2026-06-13",
"input_cost_per_token": 3e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9452,6 +9536,7 @@
"supports_reasoning": true
},
"azure_ai/mistral-document-ai-2505": {
+ "deprecation_date": "2026-07-20",
"litellm_provider": "azure_ai",
"ocr_cost_per_page": 0.003,
"mode": "ocr",
@@ -9529,6 +9614,7 @@
"output_cost_per_token": 0.0
},
"azure_ai/cohere-rerank-v3.5": {
+ "deprecation_date": "2026-05-14",
"input_cost_per_query": 0.002,
"input_cost_per_token": 0.0,
"litellm_provider": "azure_ai",
@@ -9591,6 +9677,7 @@
"supports_tool_choice": true
},
"azure_ai/deepseek-r1": {
+ "deprecation_date": "2026-08-13",
"input_cost_per_token": 1.35e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9614,6 +9701,7 @@
"supports_tool_choice": true
},
"azure_ai/deepseek-v3-0324": {
+ "deprecation_date": "2026-07-13",
"input_cost_per_token": 1.14e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 128000,
@@ -9626,6 +9714,7 @@
"supports_tool_choice": true
},
"azure_ai/deepseek-v3.1": {
+ "deprecation_date": "2026-07-13",
"input_cost_per_token": 1.23e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 131072,
@@ -9639,6 +9728,7 @@
"supports_tool_choice": true
},
"azure_ai/deepseek-v4-pro": {
+ "deprecation_date": "2028-02-20",
"input_cost_per_token": 1.74e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 1000000,
@@ -9652,6 +9742,7 @@
"supports_tool_choice": true
},
"azure_ai/deepseek-v4-flash": {
+ "deprecation_date": "2028-02-20",
"input_cost_per_token": 1.9e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 1000000,
@@ -9683,6 +9774,7 @@
"supports_embedding_image_input": true
},
"azure_ai/global/grok-3": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 3e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 131072,
@@ -9697,6 +9789,7 @@
"supports_web_search": true
},
"azure_ai/global/grok-3-mini": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 2.5e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 131072,
@@ -9712,6 +9805,7 @@
"supports_web_search": true
},
"azure_ai/grok-3": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 3e-06,
"litellm_provider": "azure_ai",
"max_input_tokens": 131072,
@@ -9726,6 +9820,7 @@
"supports_web_search": true
},
"azure_ai/grok-3-mini": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 2.5e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 131072,
@@ -9773,6 +9868,7 @@
"supports_web_search": true
},
"azure_ai/grok-4-fast-non-reasoning": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 2e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
@@ -9786,6 +9882,7 @@
"supports_web_search": true
},
"azure_ai/grok-4-fast-reasoning": {
+ "deprecation_date": "2026-05-01",
"input_cost_per_token": 2e-07,
"output_cost_per_token": 5e-07,
"litellm_provider": "azure_ai",
@@ -9863,6 +9960,7 @@
"supports_tool_choice": true
},
"azure_ai/kimi-k2.5": {
+ "deprecation_date": "2027-01-26",
"input_cost_per_token": 6e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 262144,
@@ -9877,6 +9975,7 @@
"supports_vision": true
},
"azure_ai/kimi-k2.6": {
+ "deprecation_date": "2027-04-16",
"input_cost_per_token": 9.5e-07,
"litellm_provider": "azure_ai",
"max_input_tokens": 262144,
@@ -10004,6 +10103,7 @@
"supports_vision": true
},
"babbage-002": {
+ "deprecation_date": "2026-09-28",
"input_cost_per_token": 4e-07,
"litellm_provider": "text-completion-openai",
"max_input_tokens": 16384,
@@ -12014,6 +12114,7 @@
]
},
"claude-haiku-4-5-20251001": {
+ "deprecation_date": "2026-10-15",
"cache_creation_input_token_cost": 1.25e-06,
"cache_creation_input_token_cost_above_1hr": 2e-06,
"cache_read_input_token_cost": 1e-07,
@@ -12037,6 +12138,7 @@
"prompt_cache_min_tokens": 4096
},
"claude-haiku-4-5": {
+ "deprecation_date": "2026-10-15",
"cache_creation_input_token_cost": 1.25e-06,
"cache_creation_input_token_cost_above_1hr": 2e-06,
"cache_read_input_token_cost": 1e-07,
@@ -12185,6 +12287,7 @@
"prompt_cache_min_tokens": 1024
},
"claude-sonnet-4-5": {
+ "deprecation_date": "2026-09-29",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05,
@@ -12218,6 +12321,7 @@
"prompt_cache_min_tokens": 1024
},
"claude-sonnet-4-5-20250929": {
+ "deprecation_date": "2026-09-29",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 1.2e-05,
@@ -12252,6 +12356,7 @@
"prompt_cache_min_tokens": 1024
},
"claude-sonnet-5": {
+ "deprecation_date": "2027-06-30",
"cache_creation_input_token_cost": 2.5e-06,
"cache_creation_input_token_cost_above_1hr": 4e-06,
"cache_read_input_token_cost": 2e-07,
@@ -12288,6 +12393,7 @@
"prompt_cache_min_tokens": 1024
},
"claude-sonnet-4-6": {
+ "deprecation_date": "2027-02-17",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -12434,6 +12540,7 @@
"prompt_cache_min_tokens": 1024
},
"claude-opus-4-5-20251101": {
+ "deprecation_date": "2026-11-24",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12463,6 +12570,7 @@
"prompt_cache_min_tokens": 4096
},
"claude-opus-4-5": {
+ "deprecation_date": "2026-11-24",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12492,6 +12600,7 @@
"prompt_cache_min_tokens": 4096
},
"claude-opus-4-6": {
+ "deprecation_date": "2027-02-05",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12528,6 +12637,7 @@
"prompt_cache_min_tokens": 4096
},
"claude-opus-4-6-20260205": {
+ "deprecation_date": "2027-02-05",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12564,6 +12674,7 @@
"prompt_cache_min_tokens": 4096
},
"claude-opus-4-7": {
+ "deprecation_date": "2027-04-16",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12602,6 +12713,7 @@
"prompt_cache_min_tokens": 2048
},
"claude-opus-4-7-20260416": {
+ "deprecation_date": "2027-04-16",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12640,6 +12752,7 @@
"prompt_cache_min_tokens": 2048
},
"claude-fable-5": {
+ "deprecation_date": "2027-06-09",
"cache_creation_input_token_cost": 1.25e-05,
"cache_creation_input_token_cost_above_1hr": 2e-05,
"cache_read_input_token_cost": 1e-06,
@@ -12675,6 +12788,7 @@
"prompt_cache_min_tokens": 512
},
"claude-opus-5": {
+ "deprecation_date": "2027-07-24",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -12713,6 +12827,7 @@
"prompt_cache_min_tokens": 512
},
"claude-opus-4-8": {
+ "deprecation_date": "2027-05-28",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -15042,6 +15157,7 @@
"mode": "search"
},
"davinci-002": {
+ "deprecation_date": "2026-09-28",
"input_cost_per_token": 2e-06,
"litellm_provider": "text-completion-openai",
"max_input_tokens": 16384,
@@ -18594,6 +18710,7 @@
}
},
"gemini-2.5-flash": {
+ "deprecation_date": "2026-10-20",
"cache_read_input_token_cost": 3e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 3e-07,
@@ -18639,6 +18756,7 @@
"supports_image_size": false
},
"gemini-2.5-flash-image": {
+ "deprecation_date": "2026-10-02",
"cache_read_input_token_cost": 3e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 3e-07,
@@ -18683,6 +18801,7 @@
"supports_image_size": false
},
"gemini-3-pro-image": {
+ "deprecation_date": "2027-05-28",
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
@@ -18763,6 +18882,7 @@
"web_search_billing_unit": "per_query"
},
"gemini-3.1-flash-image": {
+ "deprecation_date": "2027-05-28",
"input_cost_per_image": 0.00056,
"input_cost_per_token": 5e-07,
"litellm_provider": "vertex_ai-language-models",
@@ -18887,6 +19007,7 @@
"web_search_billing_unit": "per_query"
},
"gemini-3.1-flash-lite": {
+ "deprecation_date": "2027-05-07",
"cache_read_input_token_cost": 2.5e-08,
"cache_read_input_token_cost_flex": 1.25e-08,
"cache_read_input_token_cost_priority": 4.5e-08,
@@ -18943,6 +19064,7 @@
"web_search_billing_unit": "per_query"
},
"gemini-3.5-flash-lite": {
+ "deprecation_date": "2027-07-21",
"cache_read_input_token_cost": 3e-08,
"cache_read_input_token_cost_flex": 2e-08,
"cache_read_input_token_cost_priority": 5e-08,
@@ -19032,6 +19154,7 @@
"supports_web_search": true
},
"gemini-2.5-flash-lite": {
+ "deprecation_date": "2026-10-20",
"cache_read_input_token_cost": 1e-08,
"input_cost_per_audio_token": 3e-07,
"input_cost_per_token": 1e-07,
@@ -19303,6 +19426,7 @@
"supports_image_size": false
},
"gemini-2.5-pro": {
+ "deprecation_date": "2026-10-20",
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost_above_200k_tokens": 2.5e-07,
"cache_creation_input_token_cost_above_200k_tokens": 2.5e-07,
@@ -19617,6 +19741,7 @@
},
"vertex_ai/gemini-3.5-flash": {
"prompt_cache_min_tokens": 4096,
+ "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,
@@ -20057,6 +20182,7 @@
"web_search_billing_unit": "per_query"
},
"gemini/gemini-robotics-er-1.6-preview": {
+ "deprecation_date": "2026-08-31",
"input_cost_per_audio_token": 2e-06,
"input_cost_per_token": 1e-06,
"litellm_provider": "gemini",
@@ -20127,6 +20253,7 @@
"supports_vision": true
},
"gemini-embedding-001": {
+ "deprecation_date": "2028-05-20",
"input_cost_per_token": 1.5e-07,
"litellm_provider": "vertex_ai-embedding-models",
"max_input_tokens": 2048,
@@ -21746,6 +21873,7 @@
},
"gemini-3.5-flash": {
"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_token": 1.5e-06,
@@ -23260,6 +23388,7 @@
"supports_tool_choice": true
},
"gpt-3.5-turbo-instruct": {
+ "deprecation_date": "2026-09-28",
"input_cost_per_token": 1.5e-06,
"litellm_provider": "text-completion-openai",
"max_input_tokens": 8192,
@@ -24391,6 +24520,7 @@
"supports_pdf_input": true
},
"low/1024-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.009,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24402,6 +24532,7 @@
"supports_pdf_input": true
},
"low/1024-x-1536/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24413,6 +24544,7 @@
"supports_pdf_input": true
},
"low/1536-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24424,6 +24556,7 @@
"supports_pdf_input": true
},
"medium/1024-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.034,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24435,6 +24568,7 @@
"supports_pdf_input": true
},
"medium/1024-x-1536/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.05,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24446,6 +24580,7 @@
"supports_pdf_input": true
},
"medium/1536-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.05,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24457,6 +24592,7 @@
"supports_pdf_input": true
},
"high/1024-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.133,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24468,6 +24604,7 @@
"supports_pdf_input": true
},
"high/1024-x-1536/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.2,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24479,6 +24616,7 @@
"supports_pdf_input": true
},
"high/1536-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.2,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24490,6 +24628,7 @@
"supports_pdf_input": true
},
"standard/1024-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.009,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24501,6 +24640,7 @@
"supports_pdf_input": true
},
"standard/1024-x-1536/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24512,6 +24652,7 @@
"supports_pdf_input": true
},
"standard/1536-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24523,6 +24664,7 @@
"supports_pdf_input": true
},
"1024-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.009,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24534,6 +24676,7 @@
"supports_pdf_input": true
},
"1024-x-1536/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -24545,6 +24688,7 @@
"supports_pdf_input": true
},
"1536-x-1024/gpt-image-1.5": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.013,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -27458,18 +27602,21 @@
"output_cost_per_second": 0.0
},
"hd/1024-x-1024/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 7.629e-08,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_pixel": 0.0
},
"hd/1024-x-1792/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 6.539e-08,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_pixel": 0.0
},
"hd/1792-x-1024/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 6.539e-08,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -27516,6 +27663,7 @@
"max_output_tokens": 8192
},
"high/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.167,
"input_cost_per_pixel": 1.59263611e-07,
"litellm_provider": "openai",
@@ -27526,6 +27674,7 @@
]
},
"high/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.25,
"input_cost_per_pixel": 1.58945719e-07,
"litellm_provider": "openai",
@@ -27536,6 +27685,7 @@
]
},
"high/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.25,
"input_cost_per_pixel": 1.58945719e-07,
"litellm_provider": "openai",
@@ -28323,6 +28473,7 @@
"supports_tool_choice": true
},
"low/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.011,
"input_cost_per_pixel": 1.0490417e-08,
"litellm_provider": "openai",
@@ -28333,6 +28484,7 @@
]
},
"low/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.016,
"input_cost_per_pixel": 1.0172526e-08,
"litellm_provider": "openai",
@@ -28343,6 +28495,7 @@
]
},
"low/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.016,
"input_cost_per_pixel": 1.0172526e-08,
"litellm_provider": "openai",
@@ -28367,6 +28520,7 @@
"output_cost_per_image": 0.072
},
"medium/1024-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.042,
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "openai",
@@ -28377,6 +28531,7 @@
]
},
"medium/1024-x-1536/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.063,
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "openai",
@@ -28387,6 +28542,7 @@
]
},
"medium/1536-x-1024/gpt-image-1": {
+ "deprecation_date": "2026-10-23",
"input_cost_per_image": 0.063,
"input_cost_per_pixel": 4.0054321e-08,
"litellm_provider": "openai",
@@ -28397,6 +28553,7 @@
]
},
"low/1024-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.005,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -28405,6 +28562,7 @@
]
},
"low/1024-x-1536/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.006,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -28413,6 +28571,7 @@
]
},
"low/1536-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.006,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -28421,6 +28580,7 @@
]
},
"medium/1024-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.011,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -28429,6 +28589,7 @@
]
},
"medium/1024-x-1536/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.015,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -28437,6 +28598,7 @@
]
},
"medium/1536-x-1024/gpt-image-1-mini": {
+ "deprecation_date": "2026-12-01",
"input_cost_per_image": 0.015,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -30330,6 +30492,7 @@
]
},
"multimodalembedding@001": {
+ "deprecation_date": "2027-04-01",
"input_cost_per_character": 2e-07,
"input_cost_per_image": 0.0001,
"input_cost_per_token": 8e-07,
@@ -36028,18 +36191,21 @@
"output_cost_per_image": 0.14
},
"standard/1024-x-1024/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 3.81469e-08,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_pixel": 0.0
},
"standard/1024-x-1792/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 4.359e-08,
"litellm_provider": "openai",
"mode": "image_generation",
"output_cost_per_pixel": 0.0
},
"standard/1792-x-1024/dall-e-3": {
+ "deprecation_date": "2026-05-12",
"input_cost_per_pixel": 4.359e-08,
"litellm_provider": "openai",
"mode": "image_generation",
@@ -36103,6 +36269,7 @@
"source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models"
},
"text-embedding-005": {
+ "deprecation_date": "2027-04-01",
"input_cost_per_character": 2.5e-08,
"input_cost_per_token": 1e-07,
"litellm_provider": "vertex_ai-embedding-models",
@@ -36176,6 +36343,7 @@
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing"
},
"text-moderation-007": {
+ "deprecation_date": "2025-10-27",
"input_cost_per_token": 0.0,
"litellm_provider": "openai",
"max_input_tokens": 32768,
@@ -36185,6 +36353,7 @@
"output_cost_per_token": 0.0
},
"text-moderation-latest": {
+ "deprecation_date": "2025-10-27",
"input_cost_per_token": 0.0,
"litellm_provider": "openai",
"max_input_tokens": 32768,
@@ -36194,6 +36363,7 @@
"output_cost_per_token": 0.0
},
"text-moderation-stable": {
+ "deprecation_date": "2025-10-27",
"input_cost_per_token": 0.0,
"litellm_provider": "openai",
"max_input_tokens": 32768,
@@ -36203,6 +36373,7 @@
"output_cost_per_token": 0.0
},
"text-multilingual-embedding-002": {
+ "deprecation_date": "2027-04-01",
"input_cost_per_character": 2.5e-08,
"input_cost_per_token": 1e-07,
"litellm_provider": "vertex_ai-embedding-models",
@@ -38690,6 +38861,7 @@
"supports_tool_choice": true
},
"vertex_ai/claude-haiku-4-5": {
+ "deprecation_date": "2026-10-15",
"cache_creation_input_token_cost": 1.25e-06,
"cache_creation_input_token_cost_above_1hr": 2e-06,
"cache_read_input_token_cost": 1e-07,
@@ -38713,6 +38885,7 @@
"prompt_cache_min_tokens": 4096
},
"vertex_ai/claude-haiku-4-5@20251001": {
+ "deprecation_date": "2026-10-15",
"cache_creation_input_token_cost": 1.25e-06,
"cache_creation_input_token_cost_above_1hr": 2e-06,
"cache_read_input_token_cost": 1e-07,
@@ -38865,6 +39038,7 @@
"supports_vision": true
},
"vertex_ai/claude-opus-4": {
+ "deprecation_date": "2026-05-14",
"cache_creation_input_token_cost": 1.875e-05,
"cache_creation_input_token_cost_above_1hr": 3e-05,
"cache_read_input_token_cost": 1.5e-06,
@@ -38892,6 +39066,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-opus-4-1": {
+ "deprecation_date": "2026-08-05",
"cache_creation_input_token_cost": 1.875e-05,
"cache_creation_input_token_cost_above_1hr": 3e-05,
"cache_read_input_token_cost": 1.5e-06,
@@ -38910,6 +39085,7 @@
"supports_vision": true
},
"vertex_ai/claude-opus-4-1@20250805": {
+ "deprecation_date": "2026-08-05",
"cache_creation_input_token_cost": 1.875e-05,
"cache_creation_input_token_cost_above_1hr": 3e-05,
"cache_read_input_token_cost": 1.5e-06,
@@ -38928,6 +39104,7 @@
"supports_vision": true
},
"vertex_ai/claude-opus-4-5": {
+ "deprecation_date": "2026-11-24",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -38956,6 +39133,7 @@
"prompt_cache_min_tokens": 4096
},
"vertex_ai/claude-opus-4-5@20251101": {
+ "deprecation_date": "2026-11-24",
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
@@ -38985,6 +39163,7 @@
"prompt_cache_min_tokens": 4096
},
"vertex_ai/claude-opus-4-6": {
+ "deprecation_date": "2027-02-05",
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
@@ -39015,6 +39194,7 @@
"prompt_cache_min_tokens": 4096
},
"vertex_ai/claude-opus-4-6@default": {
+ "deprecation_date": "2027-02-05",
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
@@ -39045,6 +39225,7 @@
"prompt_cache_min_tokens": 4096
},
"vertex_ai/claude-opus-4-7": {
+ "deprecation_date": "2027-04-16",
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
@@ -39076,6 +39257,7 @@
"prompt_cache_min_tokens": 2048
},
"vertex_ai/claude-opus-4-7@default": {
+ "deprecation_date": "2027-04-16",
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
@@ -39107,6 +39289,7 @@
"prompt_cache_min_tokens": 2048
},
"vertex_ai/claude-fable-5": {
+ "deprecation_date": "2027-06-08",
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 1.25e-05,
"cache_creation_input_token_cost_above_1hr": 2e-05,
@@ -39138,6 +39321,7 @@
"supports_max_reasoning_effort": true
},
"vertex_ai/claude-fable-5@default": {
+ "deprecation_date": "2027-06-08",
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 1.25e-05,
"cache_creation_input_token_cost_above_1hr": 2e-05,
@@ -39169,6 +39353,7 @@
"supports_max_reasoning_effort": true
},
"vertex_ai/claude-opus-5": {
+ "deprecation_date": "2027-01-24",
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
@@ -39201,6 +39386,7 @@
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-5@default": {
+ "deprecation_date": "2027-01-24",
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
@@ -39233,6 +39419,7 @@
"prompt_cache_min_tokens": 512
},
"vertex_ai/claude-opus-4-8": {
+ "deprecation_date": "2027-05-28",
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
@@ -39265,6 +39452,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-opus-4-8@default": {
+ "deprecation_date": "2027-05-28",
"supports_mid_conversation_system": true,
"supports_adaptive_thinking": true,
"cache_creation_input_token_cost": 6.25e-06,
@@ -39297,6 +39485,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-sonnet-4-5": {
+ "deprecation_date": "2026-09-29",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -39325,6 +39514,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-sonnet-5": {
+ "deprecation_date": "2026-12-24",
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 2.5e-06,
"cache_creation_input_token_cost_above_1hr": 4e-06,
@@ -39387,6 +39577,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-sonnet-4-5@20250929": {
+ "deprecation_date": "2026-09-29",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -39416,6 +39607,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-opus-4@20250514": {
+ "deprecation_date": "2026-05-14",
"cache_creation_input_token_cost": 1.875e-05,
"cache_creation_input_token_cost_above_1hr": 3e-05,
"cache_read_input_token_cost": 1.5e-06,
@@ -39443,6 +39635,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-sonnet-4": {
+ "deprecation_date": "2026-05-14",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -39474,6 +39667,7 @@
"prompt_cache_min_tokens": 1024
},
"vertex_ai/claude-sonnet-4@20250514": {
+ "deprecation_date": "2026-05-14",
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
"cache_read_input_token_cost": 3e-07,
@@ -39638,6 +39832,7 @@
"supports_tool_choice": true
},
"vertex_ai/gemini-2.5-flash-image": {
+ "deprecation_date": "2026-10-02",
"cache_read_input_token_cost": 3e-08,
"input_cost_per_audio_token": 1e-06,
"input_cost_per_token": 3e-07,
@@ -39683,6 +39878,7 @@
"supports_image_size": false
},
"vertex_ai/gemini-3-pro-image": {
+ "deprecation_date": "2027-05-28",
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
@@ -39715,6 +39911,7 @@
"source": "https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-pro-image"
},
"vertex_ai/gemini-3.1-flash-image": {
+ "deprecation_date": "2027-05-28",
"input_cost_per_image": 0.00056,
"input_cost_per_token": 5e-07,
"litellm_provider": "vertex_ai-language-models",
@@ -39791,6 +39988,7 @@
"web_search_billing_unit": "per_query"
},
"vertex_ai/gemini-3.1-flash-lite": {
+ "deprecation_date": "2027-05-07",
"cache_read_input_token_cost": 2.5e-08,
"cache_read_input_token_cost_flex": 1.25e-08,
"cache_read_input_token_cost_priority": 4.5e-08,
@@ -39847,6 +40045,7 @@
"web_search_billing_unit": "per_query"
},
"vertex_ai/gemini-3.5-flash-lite": {
+ "deprecation_date": "2027-07-21",
"cache_read_input_token_cost": 3e-08,
"cache_read_input_token_cost_flex": 2e-08,
"cache_read_input_token_cost_priority": 5e-08,
@@ -40564,6 +40763,7 @@
"supports_tool_choice": true
},
"vertex_ai/veo-2.0-generate-001": {
+ "deprecation_date": "2026-06-30",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,
@@ -40578,6 +40778,7 @@
]
},
"vertex_ai/veo-3.0-fast-generate-001": {
+ "deprecation_date": "2026-06-30",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,
@@ -40592,6 +40793,7 @@
]
},
"vertex_ai/veo-3.0-generate-001": {
+ "deprecation_date": "2026-06-30",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,
@@ -40634,6 +40836,7 @@
]
},
"vertex_ai/veo-3.1-generate-001": {
+ "deprecation_date": "2026-11-17",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,
@@ -40648,6 +40851,7 @@
]
},
"vertex_ai/veo-3.1-fast-generate-001": {
+ "deprecation_date": "2026-11-17",
"litellm_provider": "vertex_ai-video-models",
"max_input_tokens": 1024,
"max_tokens": 1024,
@@ -47029,6 +47233,7 @@
}
},
"vertex_ai/claude-sonnet-5@default": {
+ "deprecation_date": "2026-12-24",
"supports_mid_conversation_system": true,
"cache_creation_input_token_cost": 2.5e-06,
"cache_creation_input_token_cost_above_1hr": 4e-06,
diff --git a/schema.prisma b/schema.prisma
index 52fb447157b..f79e2bb0c18 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -1467,28 +1467,38 @@ model LiteLLM_AutoRouterSession {
@@index([last_turn_at], map: "idx_autorouter_session_last_turn")
}
-// Shadow eval: evaluation of an auto-router against a key's live traffic, in either
-// direction. forward duplicates the requests the key did not route through the router
-// through it, answering whether the key should adopt it; reverse duplicates the requests
-// the router did serve against a fixed baseline model, answering whether a key already on
-// it still benefits. Either way a sampled slice runs in a detached task and an LLM judge
-// compares real vs shadow responses blind. The job row is immutable config plus
-// stopped_at; every count, status, and spend figure is derived from the append-only
-// attempt rows, so nothing can disagree across pods or stop races.
+// Shadow eval: evaluation of an auto-router against one or more keys' live traffic, in
+// either direction. forward duplicates the requests the keys did not route through the
+// router through it, answering whether they should adopt it; reverse duplicates the
+// requests the router did serve against a fixed baseline model, answering whether a key
+// already on it still benefits. Either way a sampled slice runs in a detached task and an
+// LLM judge compares real vs shadow responses blind. Each row is ONE key's leg of a job:
+// immutable config plus that key's own turn budget and stop state, so one key exhausting
+// its budget never ends a sibling's sampling. A job is the set of legs sharing group_id
+// (the id the API reports), written together by one atomic create_many with identical
+// config; single-key jobs predating group_id were backfilled group_id = id. "One active
+// job per (key, direction)" is a partial unique index on (api_key_id, direction) WHERE
+// stopped_at IS NULL, expressed only in the migration because schema.prisma cannot state
+// partial indexes; it is what makes a concurrent start on another pod race-safe rather
+// than read-then-create. Every count, status, and spend figure is derived from the
+// append-only attempt rows, so nothing can disagree across pods or stop races.
model LiteLLM_ShadowEvalJob {
id String @id @default(cuid())
- api_key_id String // hashed virtual key whose traffic is shadowed
+ group_id String // legs of one job share this; the API's job id
+ api_key_id String // hashed virtual key whose traffic this leg shadows
router_name String // the auto-router under evaluation, in either direction
direction String @default("forward") // forward | reverse
baseline_model String? // reverse only: the fixed model the router is judged against
judge_model String
shadow_percentage Float
- max_turns Int // sample budget: judge at most this many turns
+ max_turns Int // this key's sample budget: judge at most this many turns
created_at DateTime @default(now())
created_by String?
ends_at DateTime
stopped_at DateTime?
+ stopped_by String? // operator who stopped it early; null when it ended on its own
+ @@index([group_id])
@@index([api_key_id])
@@index([created_at])
}
diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md
index 680e0dff67b..9969ed10308 100644
--- a/tests/e2e/CLAUDE.md
+++ b/tests/e2e/CLAUDE.md
@@ -71,6 +71,16 @@ Request and response bodies are typed pydantic models in `models.py`; only the f
Mark live tests with `@pytest.mark.e2e` (on the class or the module). Pure coverage of the harness itself carries no marker and runs regardless. Use `scoped_key` for a fresh all-models key that auto-deletes, `resources` when you need to create and tear down more than a key, and `unique_marker()` from `e2e_config` to keep prompts, tags, and customer ids from colliding across concurrent runs and the shared response cache
+## Record and replay fixtures
+
+`E2E_FIXTURE_MODE` selects the transport every client is built on: `live` (the default, and what an unset variable means: nothing changes), `record` (run against the live proxy and write every interaction to a fixture bundle), or `replay` (serve every interaction back from the bundle with no HTTP at all, so a replay run needs no proxy and cannot bill a provider). The seam is `select_transport` in `fixture_transport.py`, applied inside `build_proxy_client`; both transports fulfil the same `Transport` protocol, so no test or client changes shape in any mode
+
+A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per transport call in call order (`0000-post-chat-completions.json`). Auth header values are redacted on write, and file uploads store a sha256 digest instead of the bytes; response bodies are stored verbatim (a /key/generate response keeps the ephemeral virtual key it minted), which is part of why bundles are gitignored. `fixture_bundle.py` owns the format
+
+Replay matches calls per test by transport verb and path in recorded order and raises `ReplayMiss` on any drift, naming the recorded and the actual call; a passed test must also consume its whole recording, or teardown fails it naming the first leftover interaction. Either way the fix is always to re-record with `E2E_FIXTURE_MODE=record`. Record starts fresh every time: it wipes the previous bundle (refusing to wipe a directory that is not a bundle) and never reads it. A replay bundle whose manifest is older than seven days hard-fails at collection time naming the bundle's age, so replay can never certify against fixtures that have drifted more than a week from the live proxy
+
+Deliberately not here yet: canonical content-based match keys (LIT-5741), streaming chunk fidelity (LIT-5742), and scoping record/replay to provider-bound traffic (LIT-5745)
+
## Typing
The harness is fully typed with no error budget: `make lint-e2e-basedpyright` must report zero basedpyright errors, and CI enforces that on any PR touching `tests/e2e/**/*.py`. When a response field is untyped, model it in `models.py` (just the fields you read) and let pydantic validate it, rather than threading a `dict` or `Any` through the test
diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md
index dc69bd42171..67da1be9562 100644
--- a/tests/e2e/CONTRIBUTING.md
+++ b/tests/e2e/CONTRIBUTING.md
@@ -52,6 +52,17 @@ The suites run against a live proxy, so bring one up first by running the litell
Some suites need extra services the bare proxy does not start. The `logging/` OTEL trace-completeness tests read spans back from a jaeger query API at `http://localhost:16686` (override with `E2E_OTEL_QUERY_URL`); run a `jaegertracing/all-in-one` and point `PHOENIX_COLLECTOR_HTTP_ENDPOINT` at its OTLP ingest. The `mcp/` suite needs the deterministic upstream MCP server in `mcp_tests/mcp_e2e_upstream_server.py` reachable by the proxy
+### Record and replay
+
+`E2E_FIXTURE_MODE=record` runs a suite against the live proxy as usual while writing every request/response pair to a fixture bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`); `E2E_FIXTURE_MODE=replay` then runs the same suite entirely from that bundle, with no proxy traffic and no provider spend; the proxy liveness gate is skipped, so replay runs with no proxy up at all. Unset (or `live`) behaves exactly as before the knob existed
+
+```bash
+E2E_FIXTURE_MODE=record uv run pytest tests/e2e/llm_translation/ -v
+E2E_FIXTURE_MODE=replay uv run pytest tests/e2e/llm_translation/ -v
+```
+
+Replay fails hard (`ReplayMiss`) when the tests drift from the recording, and a bundle older than seven days fails at collection time naming its age; either way the fix is to re-record. See `CLAUDE.md` in this directory for the bundle format and the transport seam
+
Tests marked `@pytest.mark.e2e` hard-fail when no proxy answers `/health/liveliness`, so a run that goes red with `No live proxy` at setup means the proxy isn't up; they never skip for a missing proxy, so an absent proxy can't be mistaken for a pass
## What a complete test looks like
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index eff3b4ddf58..da2a7da0bfa 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -15,19 +15,27 @@ shared fixtures build on it.
import functools
import os
-from collections.abc import Iterator
+from collections.abc import Generator, Iterator
+from datetime import datetime, timezone
import pytest
import requests
-from e2e_config import CONTROL_PLANE_BASE_URL, PROXY_BASE_URL
+from e2e_config import CONTROL_PLANE_BASE_URL, FIXTURE_DIR, FIXTURE_MODE_RAW, PROXY_BASE_URL
from e2e_db import RESET_OPT_IN_ENV, reset_spend_logs, run_spend_log_cleanup
+from fixture_transport import (
+ fixture_mode_collection_error,
+ fixture_report_lines,
+ parse_fixture_mode,
+ replay_leftover_error,
+)
from junit_properties import attach_result_properties
from lifecycle import ProxyClientProvider, ResourceManager
from proxy_client import ProxyClient, build_proxy_client
_E2E_TEST_RAN = pytest.StashKey[bool]()
+_CALL_PASSED = pytest.StashKey[bool]()
def pytest_configure(config: pytest.Config) -> None:
@@ -49,6 +57,21 @@ def pytest_configure(config: pytest.Config) -> None:
)
+def pytest_sessionstart(session: pytest.Session) -> None:
+ """Abort before collection when E2E_FIXTURE_MODE can never work: an unknown
+ mode value, or replay against a missing, unreadable, or stale bundle (the
+ stale message names the bundle's age). Live and record modes pass through."""
+ reason = fixture_mode_collection_error(
+ FIXTURE_MODE_RAW, FIXTURE_DIR, now=datetime.now(timezone.utc)
+ )
+ if reason is not None:
+ raise pytest.UsageError(reason)
+
+
+def pytest_report_header(config: pytest.Config) -> list[str]:
+ return fixture_report_lines(FIXTURE_MODE_RAW, FIXTURE_DIR, now=datetime.now(timezone.utc))
+
+
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
"""Attach the two custom signals (suite package and covered cell ids) to every
test's user_properties so the standard JUnit report (`--junitxml`) records them
@@ -91,9 +114,12 @@ def _proxy_fail_reason() -> str | None:
def pytest_runtest_setup(item: pytest.Item) -> None:
"""Hard-fail `e2e`-marked tests unless a proxy answers its liveness probe.
Unmarked tests (unit coverage of the harness) don't touch the proxy, so they
- run even when none is up. Never skip for a missing proxy."""
+ run even when none is up. Never skip for a missing proxy. Replay mode serves
+ every call from the fixture bundle, so it needs no live proxy either."""
if item.get_closest_marker("e2e") is None:
return
+ if parse_fixture_mode(FIXTURE_MODE_RAW) == "replay":
+ return
reason = _proxy_fail_reason()
if reason is not None:
pytest.fail(reason)
@@ -110,6 +136,36 @@ def pytest_runtest_call(item: pytest.Item) -> None:
item.session.stash[_E2E_TEST_RAN] = True
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_makereport(
+ item: pytest.Item, call: pytest.CallInfo[None]
+) -> Generator[None, pytest.TestReport, pytest.TestReport]:
+ """Stash the call-phase outcome so teardown can tell a passed test from a
+ failed one without re-deriving it."""
+ report = yield
+ if report.when == "call":
+ item.stash[_CALL_PASSED] = report.passed
+ return report
+
+
+@pytest.hookimpl(wrapper=True)
+def pytest_runtest_teardown(item: pytest.Item) -> Generator[None, None, None]:
+ """In replay mode a passing test must consume its whole recording: leftover
+ interactions mean the test now makes fewer calls than it did at record time,
+ so the replay proved less than the bundle claims. The check runs after the
+ yield so fixture finalizers replay their recorded calls first. Failed tests
+ are left alone - their own failure already explains any unconsumed tail."""
+ result = yield
+ if not item.stash.get(_CALL_PASSED, False):
+ return result
+ reason = replay_leftover_error(
+ mode_raw=FIXTURE_MODE_RAW, bundle_dir=FIXTURE_DIR, test_key=item.nodeid
+ )
+ if reason is not None:
+ pytest.fail(reason)
+ return result
+
+
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
"""Once the whole e2e session is done (all suites), optionally truncate the
spend logs so the DB doesn't accumulate test rows. The truncate is destructive
diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py
index 277478eebaf..a5c3729f4be 100644
--- a/tests/e2e/e2e_config.py
+++ b/tests/e2e/e2e_config.py
@@ -13,6 +13,8 @@ from pathlib import Path
from dotenv import load_dotenv
+from fixture_transport import deterministic_marker, parse_fixture_mode
+
# Local runs keep provider / DataDog keys in tests/e2e/.env (see CONTRIBUTING.md).
# Compose injects them into the proxy container, but pytest on the host does not
# inherit that file unless we load it. override=False so a real shell export wins.
@@ -90,6 +92,15 @@ PROPAGATION_TIMEOUT = float(os.environ.get("E2E_PROPAGATION_TIMEOUT", "15"))
EXPECT_RUST = os.environ.get("E2E_EXPECT_RUST", "").strip().lower() in ("1", "true", "yes")
+# Record/replay fixture selection (see fixture_transport.py). The raw mode value
+# is parsed and validated there; "live" (the default, also for empty values)
+# means the harness behaves exactly as before this knob existed.
+FIXTURE_MODE_RAW = os.environ.get("E2E_FIXTURE_MODE", "live")
+FIXTURE_DIR = Path(
+ os.environ.get("E2E_FIXTURE_DIR", "").strip()
+ or str(Path(__file__).resolve().parent / ".fixtures")
+)
+
# Deliberately modest concurrency. The suite shares its proxy with every other
# suite in the run, and 750 users at spawn rate 50 saturated the request path hard
# enough to distort latency-sensitive neighbours (and to spend real provider money
@@ -148,7 +159,11 @@ def datadog_mcp_url(*, toolsets: str = "core") -> str:
def unique_marker() -> str:
"""A short unique token per call/run, so concurrent runs and the shared
- response cache never collide on prompts, tags, or customer ids."""
+ response cache never collide on prompts, tags, or customer ids. In record
+ and replay modes the token is deterministic per test instead, so a replay
+ run regenerates the exact requests the record run sent."""
+ if parse_fixture_mode(FIXTURE_MODE_RAW) in ("record", "replay"):
+ return deterministic_marker()
return uuid.uuid4().hex[:12]
diff --git a/tests/e2e/fixture_bundle.py b/tests/e2e/fixture_bundle.py
new file mode 100644
index 00000000000..5eff2cf2876
--- /dev/null
+++ b/tests/e2e/fixture_bundle.py
@@ -0,0 +1,314 @@
+"""On-disk fixture bundle format for record/replay e2e runs (LIT-5729).
+
+A bundle is a directory: one ``manifest.json`` (record timestamp + harness
+version + format version) plus one subdirectory per test, holding one JSON file
+per transport interaction in call order. Bundles older than
+``MAX_BUNDLE_AGE`` hard-fail replay at collection time (see conftest), so a
+green replay run can never certify against fixtures that have drifted more than
+a week from the live proxy.
+
+This module owns the format only. The transports that produce and consume it
+live in fixture_transport.py; canonical request matching, streaming chunk
+fidelity, and provider-scoping are follow-ups (LIT-5741/5742/5745) and are
+deliberately absent here, which is why every interaction file stores the full
+redacted request even though replay today matches by call order.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import re
+import shutil
+import subprocess
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Annotated, Final, Literal
+
+from pydantic import BaseModel, Field, JsonValue, TypeAdapter
+
+from e2e_http import (
+ BinaryStream,
+ NetworkError,
+ ProbeResult,
+ RateLimitedError,
+ Result,
+ StreamingResponse,
+ Success,
+ UnauthorizedError,
+ UnknownApiError,
+ ValidationError,
+)
+
+BUNDLE_FORMAT_VERSION: Final = 1
+MAX_BUNDLE_AGE: Final = timedelta(days=7)
+MANIFEST_FILENAME: Final = "manifest.json"
+
+_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
+
+
+class Manifest(BaseModel):
+ format_version: int
+ recorded_at: datetime
+ harness_version: str
+
+
+class RecordedRequest(BaseModel):
+ """The request as the transport saw it, auth header values redacted.
+
+ Replay today only matches ``method`` (the transport verb, not the HTTP verb)
+ and ``path`` in call order; the rest is stored so LIT-5741 can move to
+ content-based match keys without re-recording. File uploads store a content
+ digest instead of the bytes."""
+
+ method: str
+ path: str
+ headers: dict[str, str]
+ params: dict[str, str] = {}
+ body: JsonValue | None = None
+ form: dict[str, str] | None = None
+ file_name: str | None = None
+ file_sha256: str | None = None
+ file_bytes: int | None = None
+
+
+class RecordedResult(BaseModel):
+ """A ``Result[R]`` flattened for disk. ``data`` holds the success payload as
+ raw JSON; replay re-validates it against the ``response_type`` the caller
+ passes, exactly like a live response body."""
+
+ shape: Literal["result"] = "result"
+ kind: Literal["success", "network", "unauthorized", "rate_limited", "validation", "unknown"]
+ status_code: int | None = None
+ data: JsonValue | None = None
+ message: str | None = None
+ body: str | None = None
+ retry_after_seconds: int | None = None
+
+
+class RecordedStreaming(BaseModel):
+ shape: Literal["streaming"] = "streaming"
+ payload: StreamingResponse
+
+
+class RecordedBinary(BaseModel):
+ shape: Literal["binary"] = "binary"
+ payload: BinaryStream
+
+
+class RecordedProbe(BaseModel):
+ shape: Literal["probe"] = "probe"
+ payload: ProbeResult
+
+
+type RecordedResponse = RecordedResult | RecordedStreaming | RecordedBinary | RecordedProbe
+
+
+class Interaction(BaseModel):
+ request: RecordedRequest
+ response: Annotated[
+ RecordedResult | RecordedStreaming | RecordedBinary | RecordedProbe,
+ Field(discriminator="shape"),
+ ]
+
+
+def to_json_value(model: BaseModel) -> JsonValue:
+ return _JSON.validate_json(model.model_dump_json(by_alias=True))
+
+
+def from_result[R: BaseModel](result: Result[R]) -> RecordedResult:
+ match result:
+ case Success(status_code=status_code, data=data):
+ return RecordedResult(kind="success", status_code=status_code, data=to_json_value(data))
+ case NetworkError(message=message):
+ return RecordedResult(kind="network", message=message)
+ case UnauthorizedError():
+ return RecordedResult(kind="unauthorized")
+ case RateLimitedError(retry_after_seconds=retry_after_seconds, body=body):
+ return RecordedResult(kind="rate_limited", retry_after_seconds=retry_after_seconds, body=body)
+ case ValidationError(message=message):
+ return RecordedResult(kind="validation", message=message)
+ case UnknownApiError(status_code=status_code, body=body):
+ return RecordedResult(kind="unknown", status_code=status_code, body=body)
+
+
+def to_result[R: BaseModel](recorded: RecordedResult, response_type: type[R]) -> Result[R]:
+ match recorded.kind:
+ case "success":
+ return Success(
+ status_code=recorded.status_code or 200,
+ data=response_type.model_validate(recorded.data),
+ )
+ case "network":
+ return NetworkError(message=recorded.message or "")
+ case "unauthorized":
+ return UnauthorizedError()
+ case "rate_limited":
+ return RateLimitedError(
+ retry_after_seconds=recorded.retry_after_seconds, body=recorded.body or ""
+ )
+ case "validation":
+ return ValidationError(message=recorded.message or "")
+ case "unknown":
+ return UnknownApiError(status_code=recorded.status_code or 0, body=recorded.body or "")
+
+
+def slugify(raw: str, *, limit: int = 60) -> str:
+ clean = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw).strip("-")
+ return clean[:limit].rstrip("-")
+
+
+def slug_for_test(test_key: str) -> str:
+ """Directory name for one test's interactions: a readable tail plus a short
+ digest of the full node id, so same-named methods in different classes or
+ files never collide."""
+ digest = hashlib.sha1(test_key.encode()).hexdigest()[:8]
+ tail = slugify(test_key.rsplit("::", 1)[-1])
+ return f"{tail}-{digest}" if tail else digest
+
+
+def interaction_filename(ordinal: int, request: RecordedRequest) -> str:
+ path_part = slugify(request.path, limit=40) or "root"
+ return f"{ordinal:04d}-{request.method}-{path_part}.json"
+
+
+def harness_version() -> str:
+ try:
+ proc = subprocess.run(
+ ("git", "rev-parse", "--short", "HEAD"),
+ cwd=Path(__file__).resolve().parent,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ check=False,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return "unknown"
+ return proc.stdout.strip() or "unknown"
+
+
+@dataclass(slots=True)
+class BundleRecorder:
+ """Appends interaction files under ``root``, one subdirectory per test, with
+ a per-test ordinal that fixes replay order. ``prepare_bundle`` is the only
+ constructor: it guarantees the directory started empty with a fresh
+ manifest, so record mode never reads (or merges into) an existing bundle."""
+
+ root: Path
+ _ordinals: dict[str, int] = field(default_factory=dict)
+
+ def record(self, *, test_key: str, request: RecordedRequest, response: RecordedResponse) -> None:
+ slug = slug_for_test(test_key)
+ ordinal = self._ordinals.get(slug, 0)
+ self._ordinals[slug] = ordinal + 1
+ directory = self.root / slug
+ directory.mkdir(parents=True, exist_ok=True)
+ interaction = Interaction(request=request, response=response)
+ target = directory / interaction_filename(ordinal, request)
+ target.write_text(interaction.model_dump_json(indent=2), encoding="utf-8")
+
+
+@dataclass(frozen=True, slots=True)
+class UnsafeBundleDir:
+ path: Path
+ reason: str
+
+
+def prepare_bundle(root: Path) -> BundleRecorder | UnsafeBundleDir:
+ """Start a fresh bundle at ``root`` for record mode: wipe whatever bundle is
+ there and write a new manifest. Refuses to wipe a directory that is neither
+ empty nor a bundle (no manifest.json), so a mistyped E2E_FIXTURE_DIR can
+ never delete unrelated files."""
+ if root.exists():
+ if not root.is_dir():
+ return UnsafeBundleDir(path=root, reason="exists and is not a directory")
+ entries = tuple(root.iterdir())
+ if entries and not (root / MANIFEST_FILENAME).is_file():
+ return UnsafeBundleDir(
+ path=root,
+ reason=f"is not empty and has no {MANIFEST_FILENAME}; refusing to wipe a non-bundle directory",
+ )
+ shutil.rmtree(root)
+ root.mkdir(parents=True)
+ manifest = Manifest(
+ format_version=BUNDLE_FORMAT_VERSION,
+ recorded_at=datetime.now(timezone.utc),
+ harness_version=harness_version(),
+ )
+ (root / MANIFEST_FILENAME).write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
+ return BundleRecorder(root=root)
+
+
+@dataclass(frozen=True, slots=True)
+class FreshBundle:
+ manifest: Manifest
+
+
+@dataclass(frozen=True, slots=True)
+class StaleBundle:
+ recorded_at: datetime
+ age: timedelta
+ limit: timedelta
+
+
+@dataclass(frozen=True, slots=True)
+class UnreadableBundle:
+ reason: str
+
+
+type BundleFreshness = FreshBundle | StaleBundle | UnreadableBundle
+
+
+def _read_manifest(root: Path) -> Manifest | UnreadableBundle:
+ manifest_path = root / MANIFEST_FILENAME
+ if not manifest_path.is_file():
+ return UnreadableBundle(reason=f"no {MANIFEST_FILENAME} found (record one with E2E_FIXTURE_MODE=record)")
+ try:
+ return Manifest.model_validate_json(manifest_path.read_text(encoding="utf-8"))
+ except ValueError as exc:
+ return UnreadableBundle(reason=f"{MANIFEST_FILENAME} is invalid: {exc}")
+
+
+def check_freshness(root: Path, *, now: datetime) -> BundleFreshness:
+ manifest = _read_manifest(root)
+ if isinstance(manifest, UnreadableBundle):
+ return manifest
+ if manifest.format_version != BUNDLE_FORMAT_VERSION:
+ return UnreadableBundle(
+ reason=f"format_version {manifest.format_version} != supported {BUNDLE_FORMAT_VERSION}"
+ )
+ recorded_at = (
+ manifest.recorded_at
+ if manifest.recorded_at.tzinfo is not None
+ else manifest.recorded_at.replace(tzinfo=timezone.utc)
+ )
+ age = now - recorded_at
+ if age > MAX_BUNDLE_AGE:
+ return StaleBundle(recorded_at=recorded_at, age=age, limit=MAX_BUNDLE_AGE)
+ return FreshBundle(manifest=manifest)
+
+
+def format_age(age: timedelta) -> str:
+ total_hours = int(age.total_seconds()) // 3600
+ return f"{total_hours // 24}d{total_hours % 24}h"
+
+
+@dataclass(frozen=True, slots=True)
+class LoadedBundle:
+ manifest: Manifest
+ interactions: dict[str, tuple[Interaction, ...]]
+
+
+def load_bundle(root: Path) -> LoadedBundle | UnreadableBundle:
+ manifest = _read_manifest(root)
+ if isinstance(manifest, UnreadableBundle):
+ return manifest
+ interactions = {
+ directory.name: tuple(
+ Interaction.model_validate_json(file.read_text(encoding="utf-8"))
+ for file in sorted(directory.glob("*.json"))
+ )
+ for directory in sorted(root.iterdir())
+ if directory.is_dir()
+ }
+ return LoadedBundle(manifest=manifest, interactions=interactions)
diff --git a/tests/e2e/fixture_transport.py b/tests/e2e/fixture_transport.py
new file mode 100644
index 00000000000..b99b0d2d80d
--- /dev/null
+++ b/tests/e2e/fixture_transport.py
@@ -0,0 +1,574 @@
+"""Record/replay transports behind the same ``Transport`` protocol (LIT-5729).
+
+``RecordingTransport`` decorates the live transport: every call passes through
+unchanged and its request/response pair is appended to the fixture bundle.
+``ReplayTransport`` implements the protocol from a recorded bundle alone: no
+HTTP, no proxy, no provider spend. Because both fulfil ``Transport``, no test
+or client changes shape; ``build_proxy_client`` picks the transport from
+``E2E_FIXTURE_MODE`` (live | record | replay, default live).
+
+Replay matches each call by test node id and call order, verifying transport
+verb + path and failing hard on any drift (``ReplayMiss``). Canonical
+content-based match keys are LIT-5741; streaming chunk fidelity is LIT-5742;
+scoping record/replay to provider-bound traffic is LIT-5745.
+"""
+
+from __future__ import annotations
+
+import functools
+import hashlib
+import os
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Final, Literal, assert_never
+
+from pydantic import BaseModel
+
+from e2e_http import AuthHeaders, BinaryStream, ProbeResult, Result, StreamingResponse
+from fixture_bundle import (
+ BundleRecorder,
+ FreshBundle,
+ Interaction,
+ LoadedBundle,
+ RecordedBinary,
+ RecordedProbe,
+ RecordedRequest,
+ RecordedResponse,
+ RecordedResult,
+ RecordedStreaming,
+ StaleBundle,
+ UnreadableBundle,
+ UnsafeBundleDir,
+ check_freshness,
+ format_age,
+ from_result,
+ load_bundle,
+ prepare_bundle,
+ slug_for_test,
+ to_json_value,
+ to_result,
+)
+from transport import Transport
+
+type FixtureMode = Literal["live", "record", "replay"]
+
+FIXTURE_MODES: Final[tuple[FixtureMode, ...]] = ("live", "record", "replay")
+
+SESSION_TEST_KEY: Final = "session"
+
+REDACTED_HEADER_NAMES: Final[frozenset[str]] = frozenset({"authorization", "x-litellm-api-key"})
+REDACTED_VALUE: Final = " {emptyResultsText(job, resultsError)} {jobHeadline(job)}
- {(job.judged_count ?? 0).toLocaleString()} of {job.max_turns.toLocaleString()} turns judged ·{" "}
+ {(job.judged_count ?? 0).toLocaleString()} of {totalBudget(job).toLocaleString()} turns judged ·{" "}
{(job.error_count ?? 0).toLocaleString()} errored · {usd(job.judge_spend ?? 0)} judge spend
{active && remaining ? ` · ${remaining}` : ""}
- Option 1: Get a specific server:
- Option 2: Get a group of MCPs:
- You can also mix both:
+ Option 1: Get a specific server:
+ Option 2: Get a group of MCPs:
+ You can also mix both:
- Vector Store ID: {ingestResults[0]?.vector_store_id}
-
- Documents Ingested: {ingestResults.length}
-
+ Vector Store ID: {ingestResults[0]?.vector_store_id}
+
+ Documents Ingested: {ingestResults.length}
+ AWS S3 Vectors allows you to store and query vector embeddings directly in S3: AWS S3 Vectors allows you to store and query vector embeddings directly in S3: LiteLLM provides a server to connect to PG Vector. To use this provider: LiteLLM provides a server to connect to PG Vector. To use this provider:
- LiteLLM searches documents you have already stored in Valkey. It does not create the index or
- upload documents for you. Before creating this vector store, make sure:
-
- When a query comes in, LiteLLM converts it to an embedding with the model below and returns the
- closest matching documents from your index.
-
+ LiteLLM searches documents you have already stored in Valkey. It does not create the index or upload
+ documents for you. Before creating this vector store, make sure:
+
+ When a query comes in, LiteLLM converts it to an embedding with the model below and returns the
+ closest matching documents from your index.
+ To use Vertex AI RAG Engine:
- Note: Google Cloud has renamed this to "RAG Engine" in its console — the steps below
- still apply.
- To use Vertex AI RAG Engine:
+ Note: Google Cloud has renamed this to "RAG Engine" in its console — the steps below still
+ apply.
+ To use Vertex AI Search (Discovery Engine):
- Note: Google Cloud has renamed this to "Agent Search" in its console — the steps below
- still apply.
- To use Vertex AI Search (Discovery Engine):
+ Note: Google Cloud has renamed this to "Agent Search" in its console — the steps below
+ still apply.
+
- The Admin UI has been disabled by the administrator. To re-enable it, please update the following
- environment variable:
-
-
+ The Admin UI has been disabled by the administrator. To re-enable it, please update the following
+ environment variable:
+
+
- By default, Username is
- Need to set UI credentials or SSO?{" "}
-
- Check the documentation
-
- .
-
+ By default, Username is
+ Need to set UI credentials or SSO?{" "}
+
+ Check the documentation
+
+ .
+ "{serverName.replace(/\s+/g, "_")}"
- "dev-group"
- "Server1,dev-group"
- "{serverName.replace(/\s+/g, "_")}"
+ "dev-group"
+ "Server1,dev-group"
+
-
-
- }
- type="info"
- showIcon
- style={{ marginBottom: "16px" }}
- />
+
+
+
-
-
- }
- type="info"
- showIcon
- />
+
+
+
-
-
+
+
-
-
- }
- type="info"
- showIcon
- />
+
+
+
-
-
- }
- type="info"
- showIcon
- />
+
+
+ AUTO_REDIRECT_UI_LOGIN_TO_SSO=true in your
+ environment configuration.
+ 🚅 LiteLLM
- DISABLE_ADMIN_UI=False
- DISABLE_ADMIN_UI=False
+ admin{" "}
- and Password is your set LiteLLM Proxy
- MASTER_KEY.
- admin and
+ Password is your set LiteLLM Proxy
+ MASTER_KEY.
+ AUTO_REDIRECT_UI_LOGIN_TO_SSO=true{" "}
- in your environment configuration.
-
- }
- />
- )}
+ {uiConfig?.sso_configured &&