);
}
From ed33687422c544afa7ba6268294744b478026557 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Fri, 14 Aug 2026 17:11:05 -0700
Subject: [PATCH 15/88] feat(proxy): auto-suppress the no-Redis banner for
confirmed single-worker deployments
---
.../migration.sql | 9 ++
.../litellm_proxy_extras/schema.prisma | 11 ++
litellm/proxy/db/proxy_worker_heartbeat.py | 89 +++++++++++++++
.../health_endpoints/_health_endpoints.py | 19 +++-
litellm/proxy/proxy_server.py | 32 +++++-
litellm/proxy/schema.prisma | 11 ++
schema.prisma | 11 ++
.../proxy/db/test_proxy_worker_heartbeat.py | 81 ++++++++++++++
.../health_endpoints/test_health_endpoints.py | 105 +++++++++++++++---
.../components/NoRedisWarningBanner.test.tsx | 1 +
.../src/components/NoRedisWarningBanner.tsx | 8 +-
11 files changed, 349 insertions(+), 28 deletions(-)
create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260814000000_add_proxy_worker_heartbeat/migration.sql
create mode 100644 litellm/proxy/db/proxy_worker_heartbeat.py
create mode 100644 tests/test_litellm/proxy/db/test_proxy_worker_heartbeat.py
diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260814000000_add_proxy_worker_heartbeat/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260814000000_add_proxy_worker_heartbeat/migration.sql
new file mode 100644
index 00000000000..0a5d9df8aaf
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260814000000_add_proxy_worker_heartbeat/migration.sql
@@ -0,0 +1,9 @@
+-- CreateTable
+CREATE TABLE "LiteLLM_ProxyWorkerHeartbeat" (
+ "worker_id" TEXT NOT NULL,
+ "hostname" TEXT NOT NULL,
+ "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "last_heartbeat_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "LiteLLM_ProxyWorkerHeartbeat_pkey" PRIMARY KEY ("worker_id")
+);
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index 79d778fb464..09efef813a7 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -945,6 +945,17 @@ model LiteLLM_DailyTagSpend {
}
+// One row per live proxy worker process. Workers upsert their row on a fixed
+// heartbeat; counting rows with a recent heartbeat tells how many workers share
+// this database, which lets the Admin UI hide its "no Redis" warning for
+// deployments that are provably a single worker.
+model LiteLLM_ProxyWorkerHeartbeat {
+ worker_id String @id
+ hostname String
+ started_at DateTime @default(now())
+ last_heartbeat_at DateTime @default(now())
+}
+
// Track the status of cron jobs running. Only allow one pod to run the job at a time
model LiteLLM_CronJob {
cronjob_id String @id @default(cuid()) // Unique ID for the record
diff --git a/litellm/proxy/db/proxy_worker_heartbeat.py b/litellm/proxy/db/proxy_worker_heartbeat.py
new file mode 100644
index 00000000000..6a2a4572e43
--- /dev/null
+++ b/litellm/proxy/db/proxy_worker_heartbeat.py
@@ -0,0 +1,89 @@
+"""
+Live proxy worker census, one row per worker process.
+
+Every uvicorn worker upserts its own row on a fixed heartbeat, so counting
+rows with a recent heartbeat answers "how many workers share this database?"
+without any coordination. The Admin UI's "no Redis" banner uses that count to
+hide itself for deployments that are provably a single worker, where per-worker
+rate limits, budgets, and router state are already global. All timestamps are
+written and compared with the database's own clock, so pods with skewed clocks
+still agree.
+"""
+
+from __future__ import annotations
+
+import socket
+from typing import TYPE_CHECKING, Final
+
+from pydantic import TypeAdapter
+from typing_extensions import ReadOnly, TypedDict
+
+from litellm._logging import verbose_proxy_logger
+from litellm._uuid import uuid
+
+if TYPE_CHECKING:
+ from litellm.proxy.utils import PrismaClient
+
+PROXY_WORKER_HEARTBEAT_INTERVAL_SECONDS: Final = 60
+PROXY_WORKER_LIVENESS_WINDOW_SECONDS: Final = 3 * PROXY_WORKER_HEARTBEAT_INTERVAL_SECONDS
+STALE_ROW_RETENTION_SECONDS: Final = 3600
+
+BEAT_SQL: Final = """
+INSERT INTO "LiteLLM_ProxyWorkerHeartbeat" (worker_id, hostname, last_heartbeat_at)
+VALUES ($1, $2, NOW())
+ON CONFLICT (worker_id) DO UPDATE SET last_heartbeat_at = NOW()
+"""
+
+PRUNE_SQL: Final = """
+DELETE FROM "LiteLLM_ProxyWorkerHeartbeat"
+WHERE last_heartbeat_at < NOW() - make_interval(secs => $1)
+"""
+
+COUNT_SQL: Final = """
+SELECT COUNT(*)::int AS live_workers FROM "LiteLLM_ProxyWorkerHeartbeat"
+WHERE last_heartbeat_at > NOW() - make_interval(secs => $1)
+"""
+
+DEREGISTER_SQL: Final = """
+DELETE FROM "LiteLLM_ProxyWorkerHeartbeat" WHERE worker_id = $1
+"""
+
+
+class _LiveWorkerCountRow(TypedDict):
+ live_workers: ReadOnly[int]
+
+
+_COUNT_ROWS_ADAPTER: Final = TypeAdapter(tuple[_LiveWorkerCountRow, ...])
+
+
+class ProxyWorkerHeartbeat:
+ def __init__(self, prisma_client: PrismaClient, worker_id: str | None = None) -> None:
+ self.prisma_client: Final = prisma_client
+ self.worker_id: Final[str] = worker_id or str(uuid.uuid4())
+ self.hostname: Final = socket.gethostname()
+
+ async def beat(self) -> None:
+ try:
+ await self.prisma_client.db.execute_raw(BEAT_SQL, self.worker_id, self.hostname)
+ await self.prisma_client.db.execute_raw(PRUNE_SQL, STALE_ROW_RETENTION_SECONDS)
+ except Exception as beat_err: # noqa: BLE001 # a missed heartbeat must never take down the worker
+ verbose_proxy_logger.debug("Proxy worker heartbeat write failed: %s", beat_err)
+
+ async def deregister(self) -> None:
+ try:
+ await self.prisma_client.db.execute_raw(DEREGISTER_SQL, self.worker_id)
+ except Exception as deregister_err: # noqa: BLE001 # best-effort cleanup; the liveness window ages the row out anyway
+ verbose_proxy_logger.debug("Proxy worker heartbeat deregister failed: %s", deregister_err)
+
+
+async def count_live_proxy_workers(prisma_client: PrismaClient) -> int | None:
+ """
+ The number of workers with a recent heartbeat, or None when the database
+ cannot answer. Callers must treat None as "unknown", not as zero.
+ """
+ try:
+ rows: Final = await prisma_client.db.query_raw(COUNT_SQL, PROXY_WORKER_LIVENESS_WINDOW_SECONDS)
+ return _COUNT_ROWS_ADAPTER.validate_python(rows)[0]["live_workers"]
+ except Exception as count_err: # noqa: BLE001 # an unknown count must degrade to "warn", never to a 503
+ verbose_proxy_logger.debug("Live proxy worker count unavailable: %s", count_err)
+ return None
diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py
index e814ec42d26..33894777bc3 100644
--- a/litellm/proxy/health_endpoints/_health_endpoints.py
+++ b/litellm/proxy/health_endpoints/_health_endpoints.py
@@ -34,6 +34,7 @@ from litellm.proxy.auth.auth_utils import (
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
+from litellm.proxy.db.proxy_worker_heartbeat import count_live_proxy_workers
from litellm.proxy.health_check import (
ADMIN_ONLY_HEALTH_DISPLAY_PARAMS,
_clean_endpoint_data,
@@ -1451,7 +1452,7 @@ def callback_name(callback):
DISABLE_NO_REDIS_WARNING_ENV_VAR: Final = "LITELLM_DISABLE_NO_REDIS_WARNING"
-def _show_no_redis_warning() -> bool:
+async def _show_no_redis_warning() -> bool:
"""
Whether the UI should warn that no Redis is configured.
@@ -1461,16 +1462,22 @@ def _show_no_redis_warning() -> bool:
coordination cache (from a Redis response cache, general_settings.
coordination_redis, or the REDIS_* env fallback) and the router's own
Redis (router_settings.redis_host), which backs cooldowns and usage-based
- routing on its own. Operators who know they run one worker can silence the
- warning with LITELLM_DISABLE_NO_REDIS_WARNING=true.
+ routing on its own. A deployment whose worker-heartbeat census proves it
+ is exactly one worker needs no cross-worker coordination, so it never
+ warns; when the census is unavailable or shows more than one worker, the
+ warning stands unless LITELLM_DISABLE_NO_REDIS_WARNING=true silences it.
"""
- from litellm.proxy.proxy_server import llm_router, redis_usage_cache
+ from litellm.proxy.proxy_server import llm_router, prisma_client, redis_usage_cache
if redis_usage_cache is not None:
return False
if llm_router is not None and llm_router.cache.redis_cache is not None:
return False
- return get_secret_bool(DISABLE_NO_REDIS_WARNING_ENV_VAR, False) is not True
+ if get_secret_bool(DISABLE_NO_REDIS_WARNING_ENV_VAR, False) is True:
+ return False
+ if prisma_client is None:
+ return True
+ return await count_live_proxy_workers(prisma_client) != 1
async def _get_health_readiness_details(
@@ -1513,7 +1520,7 @@ async def _get_health_readiness_details(
# check log level
log_level_name: Final = logging.getLevelName(verbose_logger.getEffectiveLevel())
is_detailed_debug: Final = verbose_logger.isEnabledFor(logging.DEBUG)
- show_no_redis_warning: Final = _show_no_redis_warning()
+ show_no_redis_warning: Final = await _show_no_redis_warning()
# check DB
if prisma_client is not None: # if db passed in, check if it's connected
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 359187f81cb..6ee08a732f2 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -379,6 +379,10 @@ from litellm.proxy.db.gateway_request_tracking import (
GatewayRequestAccumulator,
flush_gateway_requests,
)
+from litellm.proxy.db.proxy_worker_heartbeat import (
+ PROXY_WORKER_HEARTBEAT_INTERVAL_SECONDS,
+ ProxyWorkerHeartbeat,
+)
from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed
from litellm.proxy.discovery_endpoints import ui_discovery_endpoints_router
from litellm.proxy.fine_tuning_endpoints.endpoints import router as fine_tuning_router
@@ -864,9 +868,11 @@ async def _flush_spend_logs_queue_on_shutdown() -> None:
verbose_proxy_logger.exception("Error flushing spend logs queue on shutdown: %s", e)
-async def proxy_shutdown_event():
+async def proxy_shutdown_event(worker_heartbeat: ProxyWorkerHeartbeat | None = None):
global prisma_client, master_key, user_custom_auth, user_custom_key_generate, user_custom_key_update
verbose_proxy_logger.info("Shutting down LiteLLM Proxy Server")
+ if worker_heartbeat is not None and prisma_client:
+ await worker_heartbeat.deregister()
if prisma_client:
# Drain the SGR fold first: it lives in memory, so an un-drained interval
# is lost, and a write attempted after disconnect raises
@@ -1200,7 +1206,7 @@ async def proxy_startup_event(app: FastAPI):
)
### START BATCH WRITING DB + CHECKING NEW MODELS###
- if prisma_client is not None:
+ worker_heartbeat: Final = (
await ProxyStartupEvent.initialize_scheduled_background_jobs(
general_settings=general_settings,
prisma_client=prisma_client,
@@ -1209,7 +1215,10 @@ async def proxy_startup_event(app: FastAPI):
proxy_batch_write_at=proxy_batch_write_at,
proxy_logging_obj=proxy_logging_obj,
)
-
+ if prisma_client is not None
+ else None
+ )
+ if prisma_client is not None:
await ProxyStartupEvent._update_default_team_member_budget()
## SYNC UI SETTINGS ##
@@ -1280,7 +1289,7 @@ async def proxy_startup_event(app: FastAPI):
await proxy_config.stop_auth_cache_invalidation_subscriber()
- await proxy_shutdown_event()
+ await proxy_shutdown_event(worker_heartbeat=worker_heartbeat)
def _generate_stable_operation_id(route: "APIRoute") -> str:
@@ -8665,7 +8674,7 @@ class ProxyStartupEvent:
proxy_budget_rescheduler_max_time: int,
proxy_batch_write_at: int,
proxy_logging_obj: ProxyLogging,
- ):
+ ) -> ProxyWorkerHeartbeat:
"""Initializes scheduled background jobs"""
global store_model_in_db, scheduler
@@ -8710,6 +8719,18 @@ class ProxyStartupEvent:
# Ensure minimum interval of 30 seconds for batch writing to prevent memory issues
batch_writing_interval: Final = proxy_batch_write_at + random.randint(0, 5)
+ ### PROXY WORKER HEARTBEAT ###
+ worker_heartbeat: Final = ProxyWorkerHeartbeat(prisma_client=prisma_client)
+ await worker_heartbeat.beat()
+ scheduler.add_job(
+ worker_heartbeat.beat,
+ "interval",
+ seconds=PROXY_WORKER_HEARTBEAT_INTERVAL_SECONDS,
+ id="proxy_worker_heartbeat_job",
+ replace_existing=True,
+ misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
+ )
+
### RESET BUDGET ###
if general_settings.get("disable_reset_budget", False) is False:
budget_reset_job: Final = ResetBudgetJob(
@@ -9048,6 +9069,7 @@ class ProxyStartupEvent:
"APScheduler started with memory leak prevention settings: removed jitter, increased intervals, misfire_grace_time=%s",
APSCHEDULER_MISFIRE_GRACE_TIME,
)
+ return worker_heartbeat
@classmethod
async def _initialize_spend_tracking_background_jobs(cls, scheduler: AsyncIOScheduler):
diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma
index 79d778fb464..09efef813a7 100644
--- a/litellm/proxy/schema.prisma
+++ b/litellm/proxy/schema.prisma
@@ -945,6 +945,17 @@ model LiteLLM_DailyTagSpend {
}
+// One row per live proxy worker process. Workers upsert their row on a fixed
+// heartbeat; counting rows with a recent heartbeat tells how many workers share
+// this database, which lets the Admin UI hide its "no Redis" warning for
+// deployments that are provably a single worker.
+model LiteLLM_ProxyWorkerHeartbeat {
+ worker_id String @id
+ hostname String
+ started_at DateTime @default(now())
+ last_heartbeat_at DateTime @default(now())
+}
+
// Track the status of cron jobs running. Only allow one pod to run the job at a time
model LiteLLM_CronJob {
cronjob_id String @id @default(cuid()) // Unique ID for the record
diff --git a/schema.prisma b/schema.prisma
index 79d778fb464..09efef813a7 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -945,6 +945,17 @@ model LiteLLM_DailyTagSpend {
}
+// One row per live proxy worker process. Workers upsert their row on a fixed
+// heartbeat; counting rows with a recent heartbeat tells how many workers share
+// this database, which lets the Admin UI hide its "no Redis" warning for
+// deployments that are provably a single worker.
+model LiteLLM_ProxyWorkerHeartbeat {
+ worker_id String @id
+ hostname String
+ started_at DateTime @default(now())
+ last_heartbeat_at DateTime @default(now())
+}
+
// Track the status of cron jobs running. Only allow one pod to run the job at a time
model LiteLLM_CronJob {
cronjob_id String @id @default(cuid()) // Unique ID for the record
diff --git a/tests/test_litellm/proxy/db/test_proxy_worker_heartbeat.py b/tests/test_litellm/proxy/db/test_proxy_worker_heartbeat.py
new file mode 100644
index 00000000000..2209be0dc2e
--- /dev/null
+++ b/tests/test_litellm/proxy/db/test_proxy_worker_heartbeat.py
@@ -0,0 +1,81 @@
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from litellm.proxy.db.proxy_worker_heartbeat import (
+ BEAT_SQL,
+ COUNT_SQL,
+ DEREGISTER_SQL,
+ PROXY_WORKER_LIVENESS_WINDOW_SECONDS,
+ PRUNE_SQL,
+ STALE_ROW_RETENTION_SECONDS,
+ ProxyWorkerHeartbeat,
+ count_live_proxy_workers,
+)
+
+
+def _prisma():
+ prisma = MagicMock()
+ prisma.db.execute_raw = AsyncMock()
+ prisma.db.query_raw = AsyncMock()
+ return prisma
+
+
+@pytest.mark.asyncio
+async def test_beat_upserts_own_row_then_prunes_stale_rows():
+ prisma = _prisma()
+ heartbeat = ProxyWorkerHeartbeat(prisma_client=prisma, worker_id="worker-1")
+ await heartbeat.beat()
+ calls = prisma.db.execute_raw.call_args_list
+ assert calls[0].args == (BEAT_SQL, "worker-1", heartbeat.hostname)
+ assert calls[1].args == (PRUNE_SQL, STALE_ROW_RETENTION_SECONDS)
+
+
+@pytest.mark.asyncio
+async def test_beat_survives_a_database_error():
+ prisma = _prisma()
+ prisma.db.execute_raw = AsyncMock(side_effect=RuntimeError("db down"))
+ await ProxyWorkerHeartbeat(prisma_client=prisma).beat()
+
+
+def test_each_worker_process_gets_its_own_id():
+ prisma = _prisma()
+ first = ProxyWorkerHeartbeat(prisma_client=prisma)
+ second = ProxyWorkerHeartbeat(prisma_client=prisma)
+ assert first.worker_id != second.worker_id
+
+
+@pytest.mark.asyncio
+async def test_deregister_deletes_only_its_own_row():
+ prisma = _prisma()
+ await ProxyWorkerHeartbeat(prisma_client=prisma, worker_id="worker-1").deregister()
+ assert prisma.db.execute_raw.call_args.args == (DEREGISTER_SQL, "worker-1")
+
+
+@pytest.mark.asyncio
+async def test_deregister_survives_a_database_error():
+ prisma = _prisma()
+ prisma.db.execute_raw = AsyncMock(side_effect=RuntimeError("db down"))
+ await ProxyWorkerHeartbeat(prisma_client=prisma, worker_id="worker-1").deregister()
+
+
+@pytest.mark.asyncio
+async def test_count_reads_workers_within_the_liveness_window():
+ prisma = _prisma()
+ prisma.db.query_raw.return_value = [{"live_workers": 3}]
+ assert await count_live_proxy_workers(prisma) == 3
+ assert prisma.db.query_raw.call_args.args == (COUNT_SQL, PROXY_WORKER_LIVENESS_WINDOW_SECONDS)
+
+
+@pytest.mark.asyncio
+async def test_count_returns_unknown_when_the_query_fails():
+ prisma = _prisma()
+ prisma.db.query_raw.side_effect = RuntimeError("db down")
+ assert await count_live_proxy_workers(prisma) is None
+
+
+@pytest.mark.asyncio
+async def test_count_returns_unknown_for_a_malformed_row():
+ prisma = _prisma()
+ prisma.db.query_raw.return_value = [{"unexpected": "shape"}]
+ assert await count_live_proxy_workers(prisma) is None
diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py
index e2705bd5fec..831f659051c 100644
--- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py
+++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py
@@ -2467,61 +2467,140 @@ class TestNoRedisWarning:
def _router(redis_cache):
return SimpleNamespace(cache=SimpleNamespace(redis_cache=redis_cache))
- def test_warns_when_no_redis_is_configured(self, monkeypatch):
+ @staticmethod
+ def _prisma_with_workers(live_workers=None, error=None):
+ prisma = MagicMock()
+ if error is not None:
+ prisma.db.query_raw = AsyncMock(side_effect=error)
+ else:
+ prisma.db.query_raw = AsyncMock(return_value=[{"live_workers": live_workers}])
+ return prisma
+
+ @pytest.mark.asyncio
+ async def test_warns_when_no_redis_and_no_db_to_count_workers(self, monkeypatch):
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
+ patch("litellm.proxy.proxy_server.prisma_client", None),
):
- assert _show_no_redis_warning() is True
+ assert await _show_no_redis_warning() is True
- def test_warns_when_there_is_no_router_at_all(self, monkeypatch):
+ @pytest.mark.asyncio
+ async def test_warns_when_there_is_no_router_at_all(self, monkeypatch):
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", None),
+ patch("litellm.proxy.proxy_server.prisma_client", None),
):
- assert _show_no_redis_warning() is True
+ assert await _show_no_redis_warning() is True
- def test_stays_quiet_when_a_coordination_redis_is_configured(self, monkeypatch):
+ @pytest.mark.asyncio
+ async def test_stays_quiet_for_a_confirmed_single_worker(self, monkeypatch):
+ """One live worker needs no cross-worker coordination, so no env var is needed."""
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
+ with (
+ patch("litellm.proxy.proxy_server.redis_usage_cache", None),
+ patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
+ patch("litellm.proxy.proxy_server.prisma_client", self._prisma_with_workers(1)),
+ ):
+ assert await _show_no_redis_warning() is False
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("live_workers", [2, 5])
+ async def test_warns_when_multiple_workers_share_the_db(self, monkeypatch, live_workers):
+ monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
+ with (
+ patch("litellm.proxy.proxy_server.redis_usage_cache", None),
+ patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
+ patch("litellm.proxy.proxy_server.prisma_client", self._prisma_with_workers(live_workers)),
+ ):
+ assert await _show_no_redis_warning() is True
+
+ @pytest.mark.asyncio
+ async def test_warns_when_the_worker_census_is_empty(self, monkeypatch):
+ """Zero rows means the census cannot CONFIRM a single worker, so warn."""
+ monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
+ with (
+ patch("litellm.proxy.proxy_server.redis_usage_cache", None),
+ patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
+ patch("litellm.proxy.proxy_server.prisma_client", self._prisma_with_workers(0)),
+ ):
+ assert await _show_no_redis_warning() is True
+
+ @pytest.mark.asyncio
+ async def test_warns_when_the_worker_census_query_fails(self, monkeypatch):
+ monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
+ with (
+ patch("litellm.proxy.proxy_server.redis_usage_cache", None),
+ patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
+ patch(
+ "litellm.proxy.proxy_server.prisma_client",
+ self._prisma_with_workers(error=RuntimeError("db down")),
+ ),
+ ):
+ assert await _show_no_redis_warning() is True
+
+ @pytest.mark.asyncio
+ async def test_stays_quiet_when_a_coordination_redis_is_configured(self, monkeypatch):
+ monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
+ prisma = self._prisma_with_workers(5)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", MagicMock()),
patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
+ patch("litellm.proxy.proxy_server.prisma_client", prisma),
):
- assert _show_no_redis_warning() is False
+ assert await _show_no_redis_warning() is False
+ prisma.db.query_raw.assert_not_called()
- def test_stays_quiet_when_only_the_router_has_redis(self, monkeypatch):
+ @pytest.mark.asyncio
+ async def test_stays_quiet_when_only_the_router_has_redis(self, monkeypatch):
"""router_settings.redis_host alone backs cooldowns and usage-based routing."""
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", self._router(MagicMock())),
+ patch("litellm.proxy.proxy_server.prisma_client", self._prisma_with_workers(5)),
):
- assert _show_no_redis_warning() is False
+ assert await _show_no_redis_warning() is False
+ @pytest.mark.asyncio
@pytest.mark.parametrize("value", ["true", "True"])
- def test_env_var_suppresses_the_warning(self, monkeypatch, value):
+ async def test_env_var_suppresses_the_warning_despite_multiple_workers(self, monkeypatch, value):
monkeypatch.setenv("LITELLM_DISABLE_NO_REDIS_WARNING", value)
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
+ patch("litellm.proxy.proxy_server.prisma_client", self._prisma_with_workers(5)),
):
- assert _show_no_redis_warning() is False
+ assert await _show_no_redis_warning() is False
- def test_env_var_set_false_keeps_the_warning(self, monkeypatch):
+ @pytest.mark.asyncio
+ async def test_env_var_set_false_keeps_the_warning_for_multiple_workers(self, monkeypatch):
monkeypatch.setenv("LITELLM_DISABLE_NO_REDIS_WARNING", "false")
with (
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
+ patch("litellm.proxy.proxy_server.prisma_client", self._prisma_with_workers(2)),
):
- assert _show_no_redis_warning() is True
+ assert await _show_no_redis_warning() is True
+
+ @pytest.mark.asyncio
+ async def test_env_var_set_false_does_not_force_the_warning_for_a_single_worker(self, monkeypatch):
+ monkeypatch.setenv("LITELLM_DISABLE_NO_REDIS_WARNING", "false")
+ with (
+ patch("litellm.proxy.proxy_server.redis_usage_cache", None),
+ patch("litellm.proxy.proxy_server.llm_router", self._router(None)),
+ patch("litellm.proxy.proxy_server.prisma_client", self._prisma_with_workers(1)),
+ ):
+ assert await _show_no_redis_warning() is False
@pytest.mark.asyncio
@pytest.mark.parametrize("has_prisma_client", [True, False])
async def test_readiness_details_carries_the_flag(self, monkeypatch, has_prisma_client):
monkeypatch.delenv("LITELLM_DISABLE_NO_REDIS_WARNING", raising=False)
- prisma_client = MagicMock() if has_prisma_client else None
+ prisma_client = self._prisma_with_workers(2) if has_prisma_client else None
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
patch("litellm.proxy.proxy_server.redis_usage_cache", None),
diff --git a/ui/litellm-dashboard/src/components/NoRedisWarningBanner.test.tsx b/ui/litellm-dashboard/src/components/NoRedisWarningBanner.test.tsx
index 8afde8eec94..600315e789e 100644
--- a/ui/litellm-dashboard/src/components/NoRedisWarningBanner.test.tsx
+++ b/ui/litellm-dashboard/src/components/NoRedisWarningBanner.test.tsx
@@ -20,6 +20,7 @@ describe("NoRedisWarningBanner", () => {
renderWithProviders();
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/No Redis configured\. Redis is highly recommended/i)).toBeInTheDocument();
+ expect(screen.getByText(/more than one worker/i)).toBeInTheDocument();
});
it("should link to the docs page listing what breaks without Redis", () => {
diff --git a/ui/litellm-dashboard/src/components/NoRedisWarningBanner.tsx b/ui/litellm-dashboard/src/components/NoRedisWarningBanner.tsx
index 93c0f55486d..02433fad521 100644
--- a/ui/litellm-dashboard/src/components/NoRedisWarningBanner.tsx
+++ b/ui/litellm-dashboard/src/components/NoRedisWarningBanner.tsx
@@ -26,13 +26,13 @@ export const NoRedisWarningBanner: React.FC = ({ acce
No Redis configured. Redis is highly recommended
- Rate limits, budgets, router state, and cache invalidation are per worker without Redis, so limits are
- enforced once per worker and spend can overshoot.{" "}
+ This proxy is running more than one worker (or the worker count could not be verified). Without Redis, rate
+ limits, budgets, router state, and cache invalidation are per worker, so limits are enforced once per worker
+ and spend can overshoot.{" "}
See everything that does not work without Redis
- . If you run a single worker and this is intentional, set{" "}
- LITELLM_DISABLE_NO_REDIS_WARNING=true to hide this banner.
+ . Set LITELLM_DISABLE_NO_REDIS_WARNING=true to hide this banner anyway.
From 3217b8edae27074298717b2a56fd1d5a82b6d517 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Fri, 14 Aug 2026 17:23:06 -0700
Subject: [PATCH 16/88] fix(proxy): count worker heartbeats on the primary so
replica lag cannot undercount
---
litellm/proxy/db/proxy_worker_heartbeat.py | 8 ++++++--
.../proxy/db/test_proxy_worker_heartbeat.py | 13 +++++++++++++
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/litellm/proxy/db/proxy_worker_heartbeat.py b/litellm/proxy/db/proxy_worker_heartbeat.py
index 6a2a4572e43..990ff48eb18 100644
--- a/litellm/proxy/db/proxy_worker_heartbeat.py
+++ b/litellm/proxy/db/proxy_worker_heartbeat.py
@@ -20,6 +20,7 @@ from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
+from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
@@ -79,10 +80,13 @@ class ProxyWorkerHeartbeat:
async def count_live_proxy_workers(prisma_client: PrismaClient) -> int | None:
"""
The number of workers with a recent heartbeat, or None when the database
- cannot answer. Callers must treat None as "unknown", not as zero.
+ cannot answer. Callers must treat None as "unknown", not as zero. Always
+ counts on the primary: a lagging read replica must never undercount.
"""
try:
- rows: Final = await prisma_client.db.query_raw(COUNT_SQL, PROXY_WORKER_LIVENESS_WINDOW_SECONDS)
+ db: Final = prisma_client.db
+ primary_db: Final = db.writer if isinstance(db, RoutingPrismaWrapper) else db
+ rows: Final = await primary_db.query_raw(COUNT_SQL, PROXY_WORKER_LIVENESS_WINDOW_SECONDS)
return _COUNT_ROWS_ADAPTER.validate_python(rows)[0]["live_workers"]
except Exception as count_err: # noqa: BLE001 # an unknown count must degrade to "warn", never to a 503
verbose_proxy_logger.debug("Live proxy worker count unavailable: %s", count_err)
diff --git a/tests/test_litellm/proxy/db/test_proxy_worker_heartbeat.py b/tests/test_litellm/proxy/db/test_proxy_worker_heartbeat.py
index 2209be0dc2e..33ae6190411 100644
--- a/tests/test_litellm/proxy/db/test_proxy_worker_heartbeat.py
+++ b/tests/test_litellm/proxy/db/test_proxy_worker_heartbeat.py
@@ -12,6 +12,7 @@ from litellm.proxy.db.proxy_worker_heartbeat import (
ProxyWorkerHeartbeat,
count_live_proxy_workers,
)
+from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper
def _prisma():
@@ -67,6 +68,18 @@ async def test_count_reads_workers_within_the_liveness_window():
assert prisma.db.query_raw.call_args.args == (COUNT_SQL, PROXY_WORKER_LIVENESS_WINDOW_SECONDS)
+@pytest.mark.asyncio
+async def test_count_reads_from_the_primary_when_reads_route_to_a_replica():
+ writer = MagicMock()
+ writer.query_raw = AsyncMock(return_value=[{"live_workers": 2}])
+ reader = MagicMock()
+ reader.query_raw = AsyncMock(return_value=[{"live_workers": 1}])
+ prisma = MagicMock()
+ prisma.db = RoutingPrismaWrapper(writer=writer, reader=reader)
+ assert await count_live_proxy_workers(prisma) == 2
+ reader.query_raw.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_count_returns_unknown_when_the_query_fails():
prisma = _prisma()
From 3d523d6d81816d692927e00a44ad998665731b82 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Tue, 18 Aug 2026 13:14:42 +0000
Subject: [PATCH 17/88] fix(model_prices): add provider-announced
deprecation_date to 205 registry entries
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
...odel_prices_and_context_window_backup.json | 205 ++++++++++++++++++
model_prices_and_context_window.json | 205 ++++++++++++++++++
2 files changed, 410 insertions(+)
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index 78b53cefc53..1a130c4ac0a 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,
@@ -11999,6 +12099,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,
@@ -12022,6 +12123,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,
@@ -12170,6 +12272,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,
@@ -12203,6 +12306,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,
@@ -12237,6 +12341,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,
@@ -12273,6 +12378,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,
@@ -12419,6 +12525,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,
@@ -12448,6 +12555,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,
@@ -12477,6 +12585,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,
@@ -12513,6 +12622,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,
@@ -12549,6 +12659,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,
@@ -12587,6 +12698,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,
@@ -12625,6 +12737,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,
@@ -12660,6 +12773,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,
@@ -12698,6 +12812,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,
@@ -14801,6 +14916,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,
@@ -18353,6 +18469,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,
@@ -18398,6 +18515,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,
@@ -18442,6 +18560,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,
@@ -18522,6 +18641,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",
@@ -18646,6 +18766,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,
@@ -18702,6 +18823,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,
@@ -18791,6 +18913,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,
@@ -19062,6 +19185,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,
@@ -19373,6 +19497,7 @@
"web_search_billing_unit": "per_query"
},
"vertex_ai/gemini-3.5-flash": {
+ "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,
@@ -19809,6 +19934,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",
@@ -19879,6 +20005,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,
@@ -21492,6 +21619,7 @@
"supports_vision": true
},
"gemini-3.5-flash": {
+ "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,
@@ -23004,6 +23132,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,
@@ -24135,6 +24264,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",
@@ -24146,6 +24276,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",
@@ -24157,6 +24288,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",
@@ -24168,6 +24300,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",
@@ -24179,6 +24312,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",
@@ -24190,6 +24324,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",
@@ -24201,6 +24336,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",
@@ -24212,6 +24348,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",
@@ -24223,6 +24360,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",
@@ -24234,6 +24372,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",
@@ -24245,6 +24384,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",
@@ -24256,6 +24396,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",
@@ -24267,6 +24408,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",
@@ -24278,6 +24420,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",
@@ -24289,6 +24432,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",
@@ -27202,18 +27346,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",
@@ -27260,6 +27407,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",
@@ -27270,6 +27418,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",
@@ -27280,6 +27429,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",
@@ -28067,6 +28217,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",
@@ -28077,6 +28228,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",
@@ -28087,6 +28239,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",
@@ -28111,6 +28264,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",
@@ -28121,6 +28275,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",
@@ -28131,6 +28286,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",
@@ -28141,6 +28297,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",
@@ -28149,6 +28306,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",
@@ -28157,6 +28315,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",
@@ -28165,6 +28324,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",
@@ -28173,6 +28333,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",
@@ -28181,6 +28342,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",
@@ -30074,6 +30236,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,
@@ -35772,18 +35935,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",
@@ -35847,6 +36013,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",
@@ -35920,6 +36087,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,
@@ -35929,6 +36097,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,
@@ -35938,6 +36107,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,
@@ -35947,6 +36117,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",
@@ -38434,6 +38605,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,
@@ -38457,6 +38629,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,
@@ -38609,6 +38782,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,
@@ -38636,6 +38810,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,
@@ -38654,6 +38829,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,
@@ -38672,6 +38848,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,
@@ -38700,6 +38877,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,
@@ -38729,6 +38907,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,
@@ -38759,6 +38938,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,
@@ -38789,6 +38969,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,
@@ -38820,6 +39001,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,
@@ -38851,6 +39033,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,
@@ -38882,6 +39065,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,
@@ -38913,6 +39097,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,
@@ -38945,6 +39130,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,
@@ -38977,6 +39163,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,
@@ -39009,6 +39196,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,
@@ -39041,6 +39229,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,
@@ -39069,6 +39258,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,
@@ -39131,6 +39321,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,
@@ -39160,6 +39351,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,
@@ -39187,6 +39379,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,
@@ -39218,6 +39411,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,
@@ -39382,6 +39576,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,
@@ -39427,6 +39622,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,
@@ -39459,6 +39655,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",
@@ -39535,6 +39732,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,
@@ -39591,6 +39789,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,
@@ -40308,6 +40507,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,
@@ -40322,6 +40522,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,
@@ -40336,6 +40537,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,
@@ -40378,6 +40580,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,
@@ -40392,6 +40595,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,
@@ -46773,6 +46977,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/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 78b53cefc53..1a130c4ac0a 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,
@@ -11999,6 +12099,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,
@@ -12022,6 +12123,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,
@@ -12170,6 +12272,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,
@@ -12203,6 +12306,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,
@@ -12237,6 +12341,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,
@@ -12273,6 +12378,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,
@@ -12419,6 +12525,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,
@@ -12448,6 +12555,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,
@@ -12477,6 +12585,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,
@@ -12513,6 +12622,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,
@@ -12549,6 +12659,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,
@@ -12587,6 +12698,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,
@@ -12625,6 +12737,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,
@@ -12660,6 +12773,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,
@@ -12698,6 +12812,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,
@@ -14801,6 +14916,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,
@@ -18353,6 +18469,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,
@@ -18398,6 +18515,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,
@@ -18442,6 +18560,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,
@@ -18522,6 +18641,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",
@@ -18646,6 +18766,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,
@@ -18702,6 +18823,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,
@@ -18791,6 +18913,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,
@@ -19062,6 +19185,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,
@@ -19373,6 +19497,7 @@
"web_search_billing_unit": "per_query"
},
"vertex_ai/gemini-3.5-flash": {
+ "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,
@@ -19809,6 +19934,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",
@@ -19879,6 +20005,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,
@@ -21492,6 +21619,7 @@
"supports_vision": true
},
"gemini-3.5-flash": {
+ "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,
@@ -23004,6 +23132,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,
@@ -24135,6 +24264,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",
@@ -24146,6 +24276,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",
@@ -24157,6 +24288,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",
@@ -24168,6 +24300,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",
@@ -24179,6 +24312,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",
@@ -24190,6 +24324,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",
@@ -24201,6 +24336,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",
@@ -24212,6 +24348,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",
@@ -24223,6 +24360,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",
@@ -24234,6 +24372,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",
@@ -24245,6 +24384,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",
@@ -24256,6 +24396,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",
@@ -24267,6 +24408,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",
@@ -24278,6 +24420,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",
@@ -24289,6 +24432,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",
@@ -27202,18 +27346,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",
@@ -27260,6 +27407,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",
@@ -27270,6 +27418,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",
@@ -27280,6 +27429,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",
@@ -28067,6 +28217,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",
@@ -28077,6 +28228,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",
@@ -28087,6 +28239,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",
@@ -28111,6 +28264,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",
@@ -28121,6 +28275,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",
@@ -28131,6 +28286,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",
@@ -28141,6 +28297,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",
@@ -28149,6 +28306,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",
@@ -28157,6 +28315,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",
@@ -28165,6 +28324,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",
@@ -28173,6 +28333,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",
@@ -28181,6 +28342,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",
@@ -30074,6 +30236,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,
@@ -35772,18 +35935,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",
@@ -35847,6 +36013,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",
@@ -35920,6 +36087,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,
@@ -35929,6 +36097,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,
@@ -35938,6 +36107,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,
@@ -35947,6 +36117,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",
@@ -38434,6 +38605,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,
@@ -38457,6 +38629,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,
@@ -38609,6 +38782,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,
@@ -38636,6 +38810,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,
@@ -38654,6 +38829,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,
@@ -38672,6 +38848,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,
@@ -38700,6 +38877,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,
@@ -38729,6 +38907,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,
@@ -38759,6 +38938,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,
@@ -38789,6 +38969,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,
@@ -38820,6 +39001,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,
@@ -38851,6 +39033,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,
@@ -38882,6 +39065,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,
@@ -38913,6 +39097,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,
@@ -38945,6 +39130,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,
@@ -38977,6 +39163,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,
@@ -39009,6 +39196,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,
@@ -39041,6 +39229,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,
@@ -39069,6 +39258,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,
@@ -39131,6 +39321,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,
@@ -39160,6 +39351,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,
@@ -39187,6 +39379,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,
@@ -39218,6 +39411,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,
@@ -39382,6 +39576,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,
@@ -39427,6 +39622,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,
@@ -39459,6 +39655,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",
@@ -39535,6 +39732,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,
@@ -39591,6 +39789,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,
@@ -40308,6 +40507,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,
@@ -40322,6 +40522,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,
@@ -40336,6 +40537,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,
@@ -40378,6 +40580,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,
@@ -40392,6 +40595,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,
@@ -46773,6 +46977,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,
From 2adf8aa581745284a87ce08cf40fd659a471af3e Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Tue, 18 Aug 2026 14:08:00 -0700
Subject: [PATCH 18/88] feat(e2e): add record/replay transport seam and fixture
bundle format
E2E_FIXTURE_MODE selects the transport every e2e client is built on: live
(default, unchanged behavior), record (pass through to the live proxy while
writing every interaction to a fixture bundle), or replay (serve every
interaction from the bundle with no proxy and no provider spend). Both new
transports fulfil the existing Transport protocol, so no test changes shape.
A bundle is a directory with a manifest (record timestamp, harness version,
format version) and one JSON file per interaction, grouped per test in call
order. Replay against a manifest older than seven days hard-fails at
collection time naming the bundle age. Record always wipes and never reads
the previous bundle, refusing to wipe a directory that is not a bundle.
Auth header values are redacted on write; uploads store a sha256 digest.
unique_marker() becomes deterministic per test in record/replay modes so a
replay run regenerates exactly the requests the record run sent.
Content-based match keys, streaming chunk fidelity, and provider-scoping are
follow-ups (LIT-5741, LIT-5742, LIT-5745).
---
.gitignore | 1 +
tests/e2e/CLAUDE.md | 10 +
tests/e2e/CONTRIBUTING.md | 11 +
tests/e2e/conftest.py | 28 +-
tests/e2e/e2e_config.py | 17 +-
tests/e2e/fixture_bundle.py | 314 ++++++++++++++++
tests/e2e/fixture_transport.py | 550 ++++++++++++++++++++++++++++
tests/e2e/proxy_client.py | 37 +-
tests/e2e/test_fixture_bundle.py | 218 +++++++++++
tests/e2e/test_fixture_transport.py | 438 ++++++++++++++++++++++
10 files changed, 1609 insertions(+), 15 deletions(-)
create mode 100644 tests/e2e/fixture_bundle.py
create mode 100644 tests/e2e/fixture_transport.py
create mode 100644 tests/e2e/test_fixture_bundle.py
create mode 100644 tests/e2e/test_fixture_transport.py
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/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md
index 680e0dff67b..05753c736de 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; 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..6b27bb459a5 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -16,12 +16,18 @@ shared fixtures build on it.
import functools
import os
from collections.abc import 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,
+)
from junit_properties import attach_result_properties
from lifecycle import ProxyClientProvider, ResourceManager
from proxy_client import ProxyClient, build_proxy_client
@@ -49,6 +55,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 +112,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)
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..756362cf29e
--- /dev/null
+++ b/tests/e2e/fixture_transport.py
@@ -0,0 +1,550 @@
+"""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 = ""
+
+
+@dataclass(frozen=True, slots=True)
+class InvalidFixtureMode:
+ value: str
+
+
+def parse_fixture_mode(raw: str) -> FixtureMode | InvalidFixtureMode:
+ normalized = raw.strip().lower() or "live"
+ match normalized:
+ case "live" | "record" | "replay":
+ return normalized
+ case _:
+ return InvalidFixtureMode(value=raw)
+
+
+def current_test_key() -> str:
+ """The pytest node id of the running test, from the PYTEST_CURRENT_TEST env
+ var pytest maintains (`` (setup|call|teardown)``); ``session`` for
+ calls outside any test (e.g. session-finish cleanup)."""
+ raw = os.environ.get("PYTEST_CURRENT_TEST", "")
+ if not raw:
+ return SESSION_TEST_KEY
+ return raw.rsplit(" (", 1)[0]
+
+
+class ReplayMiss(AssertionError):
+ """Replay had no recorded interaction for a call the suite made. The test
+ drifted from the bundle (or the bundle from the suite): re-record."""
+
+
+_marker_ordinals: Final[dict[str, int]] = {}
+
+
+def deterministic_marker() -> str:
+ """Stable stand-in for uuid-based unique markers in record and replay modes:
+ the Nth marker of a test is a pure function of the test's node id and N, so a
+ replay run regenerates exactly the model names, prompts, and tags the record
+ run sent and every recorded poll response still satisfies its predicate."""
+ test_key = current_test_key()
+ ordinal = _marker_ordinals.get(test_key, 0)
+ _marker_ordinals[test_key] = ordinal + 1
+ return hashlib.sha1(f"{test_key}#{ordinal}".encode()).hexdigest()[:12]
+
+
+def _dump_flat(model: BaseModel | None) -> dict[str, str]:
+ if model is None:
+ return {}
+ dumped: dict[str, object] = model.model_dump(by_alias=True, exclude_none=True)
+ return {key: str(value) for key, value in dumped.items()}
+
+
+def _redact(headers: dict[str, str]) -> dict[str, str]:
+ return {
+ name: REDACTED_VALUE if name.lower() in REDACTED_HEADER_NAMES else value
+ for name, value in headers.items()
+ }
+
+
+def recorded_request(
+ method: str,
+ path: str,
+ *,
+ headers: BaseModel,
+ body: BaseModel | None = None,
+ params: BaseModel | None = None,
+ form: BaseModel | None = None,
+ file_name: str | None = None,
+ file_content: bytes | None = None,
+) -> RecordedRequest:
+ return RecordedRequest(
+ method=method,
+ path=path,
+ headers=_redact(_dump_flat(headers)),
+ params=_dump_flat(params),
+ body=None if body is None else to_json_value(body),
+ form=None if form is None else _dump_flat(form),
+ file_name=file_name,
+ file_sha256=None if file_content is None else hashlib.sha256(file_content).hexdigest(),
+ file_bytes=None if file_content is None else len(file_content),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class RecordingTransport:
+ """Decorator over the live transport: forwards every call and appends the
+ interaction to the bundle, so a green live run leaves behind exactly the
+ traffic replay needs."""
+
+ inner: Transport
+ recorder: BundleRecorder
+
+ def _record(self, request: RecordedRequest, response: RecordedResponse) -> None:
+ self.recorder.record(test_key=current_test_key(), request=request, response=response)
+
+ def bearer(self, key: str) -> AuthHeaders:
+ return self.inner.bearer(key)
+
+ @property
+ def master(self) -> AuthHeaders:
+ return self.inner.master
+
+ def post[R: BaseModel](
+ self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
+ ) -> Result[R]:
+ result = self.inner.post(path, headers=headers, json=json, response_type=response_type)
+ self._record(recorded_request("post", path, headers=headers, body=json), from_result(result))
+ return result
+
+ def get[R: BaseModel](
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ params: BaseModel,
+ response_type: type[R],
+ timeout: float | None = None,
+ ) -> Result[R]:
+ result = self.inner.get(
+ path, headers=headers, params=params, response_type=response_type, timeout=timeout
+ )
+ self._record(recorded_request("get", path, headers=headers, params=params), from_result(result))
+ return result
+
+ def delete[R: BaseModel](
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ json: BaseModel,
+ response_type: type[R],
+ params: BaseModel | None = None,
+ ) -> Result[R]:
+ result = self.inner.delete(
+ path, headers=headers, json=json, response_type=response_type, params=params
+ )
+ self._record(
+ recorded_request("delete", path, headers=headers, body=json, params=params),
+ from_result(result),
+ )
+ return result
+
+ def patch[R: BaseModel](
+ self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
+ ) -> Result[R]:
+ result = self.inner.patch(path, headers=headers, json=json, response_type=response_type)
+ self._record(recorded_request("patch", path, headers=headers, body=json), from_result(result))
+ return result
+
+ def put[R: BaseModel](
+ self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
+ ) -> Result[R]:
+ result = self.inner.put(path, headers=headers, json=json, response_type=response_type)
+ self._record(recorded_request("put", path, headers=headers, body=json), from_result(result))
+ return result
+
+ def stream(self, path: str, *, headers: BaseModel, json: BaseModel) -> StreamingResponse:
+ response = self.inner.stream(path, headers=headers, json=json)
+ self._record(
+ recorded_request("stream", path, headers=headers, body=json),
+ RecordedStreaming(payload=response),
+ )
+ return response
+
+ def stream_binary(
+ self, path: str, *, headers: BaseModel, json: BaseModel, chunk_size: int = 8192
+ ) -> BinaryStream:
+ response = self.inner.stream_binary(path, headers=headers, json=json, chunk_size=chunk_size)
+ self._record(
+ recorded_request("stream_binary", path, headers=headers, body=json),
+ RecordedBinary(payload=response),
+ )
+ return response
+
+ def send(
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ json: BaseModel,
+ params: BaseModel | None = None,
+ stream: bool = False,
+ ) -> StreamingResponse:
+ response = self.inner.send(path, headers=headers, json=json, params=params, stream=stream)
+ self._record(
+ recorded_request("send", path, headers=headers, body=json, params=params),
+ RecordedStreaming(payload=response),
+ )
+ return response
+
+ def probe(self, path: str, *, params: BaseModel) -> ProbeResult:
+ response = self.inner.probe(path, params=params)
+ self._record(
+ recorded_request("probe", path, headers=self.master, params=params),
+ RecordedProbe(payload=response),
+ )
+ return response
+
+ def upload[R: BaseModel](
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ form: BaseModel,
+ filename: str,
+ content: bytes,
+ file_content_type: str = "application/jsonl",
+ file_field: str = "file",
+ params: BaseModel | None = None,
+ response_type: type[R],
+ ) -> Result[R]:
+ result = self.inner.upload(
+ path,
+ headers=headers,
+ form=form,
+ filename=filename,
+ content=content,
+ file_content_type=file_content_type,
+ file_field=file_field,
+ params=params,
+ response_type=response_type,
+ )
+ self._record(
+ recorded_request(
+ "upload",
+ path,
+ headers=headers,
+ params=params,
+ form=form,
+ file_name=filename,
+ file_content=content,
+ ),
+ from_result(result),
+ )
+ return result
+
+ def download(self, path: str, *, headers: BaseModel) -> StreamingResponse:
+ response = self.inner.download(path, headers=headers)
+ self._record(
+ recorded_request("download", path, headers=headers),
+ RecordedStreaming(payload=response),
+ )
+ return response
+
+
+@dataclass(slots=True)
+class ReplaySource:
+ """One shared cursor set over a loaded bundle, so every client built in the
+ session consumes the same recorded sequence per test."""
+
+ bundle: LoadedBundle
+ _cursors: dict[str, int] = field(default_factory=dict)
+
+ def next_interaction(self, method: str, path: str) -> Interaction:
+ test_key = current_test_key()
+ slug = slug_for_test(test_key)
+ recorded = self.bundle.interactions.get(slug, ())
+ index = self._cursors.get(slug, 0)
+ if index >= len(recorded):
+ raise ReplayMiss(
+ f"replay exhausted for {test_key}: call #{index + 1} ({method} {path}) has no recorded "
+ f"interaction ({len(recorded)} recorded under {slug}); re-record with E2E_FIXTURE_MODE=record"
+ )
+ interaction = recorded[index]
+ if interaction.request.method != method or interaction.request.path != path:
+ raise ReplayMiss(
+ f"replay mismatch for {test_key} at call #{index + 1}: recorded "
+ f"{interaction.request.method} {interaction.request.path}, test made {method} {path}; "
+ "re-record with E2E_FIXTURE_MODE=record"
+ )
+ self._cursors[slug] = index + 1
+ return interaction
+
+
+def _expect_result(interaction: Interaction) -> RecordedResult:
+ match interaction.response:
+ case RecordedResult() as recorded:
+ return recorded
+ case RecordedStreaming() | RecordedBinary() | RecordedProbe():
+ raise ReplayMiss(
+ f"recorded {interaction.request.method} {interaction.request.path} is not a typed result"
+ )
+
+
+def _expect_streaming(interaction: Interaction) -> StreamingResponse:
+ match interaction.response:
+ case RecordedStreaming(payload=payload):
+ return payload
+ case RecordedResult() | RecordedBinary() | RecordedProbe():
+ raise ReplayMiss(
+ f"recorded {interaction.request.method} {interaction.request.path} is not a streaming response"
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class ReplayTransport:
+ """A ``Transport`` served entirely from a recorded bundle: never opens a
+ connection, so a replay run cannot bill a provider."""
+
+ source: ReplaySource
+ master_key: str
+
+ def bearer(self, key: str) -> AuthHeaders:
+ return AuthHeaders(authorization=f"Bearer {key}")
+
+ @property
+ def master(self) -> AuthHeaders:
+ return self.bearer(self.master_key)
+
+ def post[R: BaseModel](
+ self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
+ ) -> Result[R]:
+ return to_result(_expect_result(self.source.next_interaction("post", path)), response_type)
+
+ def get[R: BaseModel](
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ params: BaseModel,
+ response_type: type[R],
+ timeout: float | None = None,
+ ) -> Result[R]:
+ return to_result(_expect_result(self.source.next_interaction("get", path)), response_type)
+
+ def delete[R: BaseModel](
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ json: BaseModel,
+ response_type: type[R],
+ params: BaseModel | None = None,
+ ) -> Result[R]:
+ return to_result(_expect_result(self.source.next_interaction("delete", path)), response_type)
+
+ def patch[R: BaseModel](
+ self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
+ ) -> Result[R]:
+ return to_result(_expect_result(self.source.next_interaction("patch", path)), response_type)
+
+ def put[R: BaseModel](
+ self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
+ ) -> Result[R]:
+ return to_result(_expect_result(self.source.next_interaction("put", path)), response_type)
+
+ def stream(self, path: str, *, headers: BaseModel, json: BaseModel) -> StreamingResponse:
+ return _expect_streaming(self.source.next_interaction("stream", path))
+
+ def stream_binary(
+ self, path: str, *, headers: BaseModel, json: BaseModel, chunk_size: int = 8192
+ ) -> BinaryStream:
+ interaction = self.source.next_interaction("stream_binary", path)
+ match interaction.response:
+ case RecordedBinary(payload=payload):
+ return payload
+ case RecordedResult() | RecordedStreaming() | RecordedProbe():
+ raise ReplayMiss(
+ f"recorded stream_binary {interaction.request.path} is not a binary stream"
+ )
+
+ def send(
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ json: BaseModel,
+ params: BaseModel | None = None,
+ stream: bool = False,
+ ) -> StreamingResponse:
+ return _expect_streaming(self.source.next_interaction("send", path))
+
+ def probe(self, path: str, *, params: BaseModel) -> ProbeResult:
+ interaction = self.source.next_interaction("probe", path)
+ match interaction.response:
+ case RecordedProbe(payload=payload):
+ return payload
+ case RecordedResult() | RecordedStreaming() | RecordedBinary():
+ raise ReplayMiss(f"recorded probe {interaction.request.path} is not a probe result")
+
+ def upload[R: BaseModel](
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ form: BaseModel,
+ filename: str,
+ content: bytes,
+ file_content_type: str = "application/jsonl",
+ file_field: str = "file",
+ params: BaseModel | None = None,
+ response_type: type[R],
+ ) -> Result[R]:
+ return to_result(_expect_result(self.source.next_interaction("upload", path)), response_type)
+
+ def download(self, path: str, *, headers: BaseModel) -> StreamingResponse:
+ return _expect_streaming(self.source.next_interaction("download", path))
+
+
+@functools.lru_cache(maxsize=8)
+def _shared_recorder(root: Path) -> BundleRecorder:
+ prepared = prepare_bundle(root)
+ if isinstance(prepared, UnsafeBundleDir):
+ raise ValueError(f"E2E_FIXTURE_DIR {prepared.path} {prepared.reason}")
+ return prepared
+
+
+@functools.lru_cache(maxsize=8)
+def _shared_replay_source(root: Path) -> ReplaySource:
+ loaded = load_bundle(root)
+ if isinstance(loaded, UnreadableBundle):
+ raise ValueError(f"cannot replay from {root}: {loaded.reason}")
+ return ReplaySource(bundle=loaded)
+
+
+def select_transport(
+ live: Transport, *, mode_raw: str, bundle_dir: Path, master_key: str
+) -> Transport:
+ """The one seam every client build goes through: wraps (record), replaces
+ (replay), or passes through (live) the transport per E2E_FIXTURE_MODE. The
+ recorder and replay cursors are process-wide singletons per bundle dir, so
+ every client in a session shares one bundle and one recorded sequence."""
+ mode = parse_fixture_mode(mode_raw)
+ match mode:
+ case InvalidFixtureMode(value=value):
+ raise ValueError(f"E2E_FIXTURE_MODE={value!r} is not one of {', '.join(FIXTURE_MODES)}")
+ case "live":
+ return live
+ case "record":
+ return RecordingTransport(inner=live, recorder=_shared_recorder(bundle_dir))
+ case "replay":
+ return ReplayTransport(source=_shared_replay_source(bundle_dir), master_key=master_key)
+ case _:
+ assert_never(mode)
+
+
+def fixture_mode_collection_error(mode_raw: str, bundle_dir: Path, *, now: datetime) -> str | None:
+ """Session-abort reason for a fixture-mode setup that can never work, or None.
+ Called at collection time (conftest pytest_sessionstart) so a stale or missing
+ bundle fails the whole run up front, naming the bundle age, instead of failing
+ every test individually."""
+ mode = parse_fixture_mode(mode_raw)
+ match mode:
+ case InvalidFixtureMode(value=value):
+ return f"E2E_FIXTURE_MODE={value!r} is not one of {', '.join(FIXTURE_MODES)}"
+ case "live" | "record":
+ return None
+ case "replay":
+ freshness = check_freshness(bundle_dir, now=now)
+ match freshness:
+ case FreshBundle():
+ return None
+ case StaleBundle(recorded_at=recorded_at, age=age, limit=limit):
+ return (
+ f"fixture bundle at {bundle_dir} is stale: recorded {recorded_at.isoformat()}, "
+ f"age {format_age(age)} exceeds the {limit.days}-day limit; "
+ "re-record with E2E_FIXTURE_MODE=record"
+ )
+ case UnreadableBundle(reason=reason):
+ return f"E2E_FIXTURE_MODE=replay cannot use bundle at {bundle_dir}: {reason}"
+ case _:
+ assert_never(freshness)
+ case _:
+ assert_never(mode)
+
+
+def fixture_report_lines(mode_raw: str, bundle_dir: Path, *, now: datetime) -> list[str]:
+ """pytest report-header lines; empty in live mode so an unset
+ E2E_FIXTURE_MODE keeps today's output byte-identical."""
+ mode = parse_fixture_mode(mode_raw)
+ match mode:
+ case InvalidFixtureMode() | "live":
+ return []
+ case "record":
+ return [f"e2e fixture mode: record -> {bundle_dir}"]
+ case "replay":
+ freshness = check_freshness(bundle_dir, now=now)
+ match freshness:
+ case FreshBundle(manifest=manifest):
+ return [
+ f"e2e fixture mode: replay <- {bundle_dir} "
+ f"(recorded {manifest.recorded_at.isoformat()}, harness {manifest.harness_version})"
+ ]
+ case StaleBundle() | UnreadableBundle():
+ return [f"e2e fixture mode: replay <- {bundle_dir}"]
+ case _:
+ assert_never(freshness)
+ case _:
+ assert_never(mode)
diff --git a/tests/e2e/proxy_client.py b/tests/e2e/proxy_client.py
index 5050b6fce68..843799ede6c 100644
--- a/tests/e2e/proxy_client.py
+++ b/tests/e2e/proxy_client.py
@@ -65,6 +65,8 @@ from models import (
)
from e2e_config import (
CONTROL_PLANE_BASE_URL,
+ FIXTURE_DIR,
+ FIXTURE_MODE_RAW,
MASTER_KEY,
POLL_INTERVAL,
POLL_TIMEOUT,
@@ -72,6 +74,7 @@ from e2e_config import (
REQUEST_TIMEOUT,
settle_propagation,
)
+from fixture_transport import select_transport
from transport import HttpTransport, SplitTransport, Transport
RowsPredicate = Callable[[list[SpendLogRow]], bool]
@@ -531,19 +534,29 @@ def build_proxy_client(
The endpoints are injectable for callers that resolve the proxy some other
way than ``e2e_config``'s env names (see ``claude_code/_env.py``); they must
pass all three together, since a caller that overrides only the data plane
- would leave management calls pointed at the env default."""
+ would leave management calls pointed at the env default.
+
+ E2E_FIXTURE_MODE wraps (record) or replaces (replay) the transport here, so
+ every client built from this seam records or replays without changing shape;
+ unset it stays the plain SplitTransport (see fixture_transport.py)."""
+ split = SplitTransport(
+ data=HttpTransport(
+ base_url=base_url,
+ master_key=master_key,
+ request_timeout=REQUEST_TIMEOUT,
+ ),
+ control=HttpTransport(
+ base_url=control_plane_base_url,
+ master_key=master_key,
+ request_timeout=REQUEST_TIMEOUT,
+ ),
+ )
return ProxyClient(
- transport=SplitTransport(
- data=HttpTransport(
- base_url=base_url,
- master_key=master_key,
- request_timeout=REQUEST_TIMEOUT,
- ),
- control=HttpTransport(
- base_url=control_plane_base_url,
- master_key=master_key,
- request_timeout=REQUEST_TIMEOUT,
- ),
+ transport=select_transport(
+ split,
+ mode_raw=FIXTURE_MODE_RAW,
+ bundle_dir=FIXTURE_DIR,
+ master_key=master_key,
),
poll_timeout=POLL_TIMEOUT,
poll_interval=POLL_INTERVAL,
diff --git a/tests/e2e/test_fixture_bundle.py b/tests/e2e/test_fixture_bundle.py
new file mode 100644
index 00000000000..fd4cca6451f
--- /dev/null
+++ b/tests/e2e/test_fixture_bundle.py
@@ -0,0 +1,218 @@
+"""Harness coverage for the on-disk fixture bundle format (LIT-5729).
+
+No proxy and no ``e2e`` marker: these pin the bundle CONTRACT - the seven-day
+freshness gate that names the bundle's age, record mode's wipe safety (never
+delete a directory that is not a bundle), collision-free per-test slugs, and
+lossless Result round-trips - so replay can never silently drift from what
+record wrote.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+import pytest
+from pydantic import BaseModel
+
+from e2e_http import (
+ NetworkError,
+ RateLimitedError,
+ Result,
+ Success,
+ UnauthorizedError,
+ UnknownApiError,
+ ValidationError,
+)
+from fixture_bundle import (
+ BUNDLE_FORMAT_VERSION,
+ MANIFEST_FILENAME,
+ MAX_BUNDLE_AGE,
+ BundleRecorder,
+ FreshBundle,
+ LoadedBundle,
+ Manifest,
+ RecordedRequest,
+ RecordedResult,
+ StaleBundle,
+ UnreadableBundle,
+ UnsafeBundleDir,
+ check_freshness,
+ format_age,
+ from_result,
+ interaction_filename,
+ load_bundle,
+ prepare_bundle,
+ slug_for_test,
+ to_result,
+)
+
+NOW = datetime(2026, 8, 18, 12, 0, 0, tzinfo=timezone.utc)
+
+
+class Payload(BaseModel):
+ value: str
+
+
+def write_manifest(
+ root: Path, recorded_at: datetime, *, format_version: int = BUNDLE_FORMAT_VERSION
+) -> None:
+ root.mkdir(parents=True, exist_ok=True)
+ manifest = Manifest(
+ format_version=format_version, recorded_at=recorded_at, harness_version="abc1234"
+ )
+ (root / MANIFEST_FILENAME).write_text(manifest.model_dump_json(), encoding="utf-8")
+
+
+def prepared(root: Path) -> BundleRecorder:
+ recorder = prepare_bundle(root)
+ assert isinstance(recorder, BundleRecorder)
+ return recorder
+
+
+def plain_request(path: str) -> RecordedRequest:
+ return RecordedRequest(method="post", path=path, headers={})
+
+
+class TestResultRoundTrip:
+ @pytest.mark.parametrize(
+ "result",
+ [
+ Success(status_code=201, data=Payload(value="ok")),
+ NetworkError(message="connection refused"),
+ UnauthorizedError(),
+ RateLimitedError(retry_after_seconds=7, body="slow down"),
+ ValidationError(message="bad shape"),
+ UnknownApiError(status_code=502, body="upstream exploded"),
+ ],
+ )
+ def test_every_result_kind_survives_disk_and_back(self, result: Result[Payload]) -> None:
+ assert to_result(from_result(result), Payload) == result
+
+
+class TestFreshness:
+ def test_bundle_at_the_limit_is_still_fresh(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ write_manifest(root, NOW - MAX_BUNDLE_AGE)
+ assert isinstance(check_freshness(root, now=NOW), FreshBundle)
+
+ def test_stale_bundle_reports_age_and_limit(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ write_manifest(root, NOW - timedelta(days=8, hours=3))
+ freshness = check_freshness(root, now=NOW)
+ assert isinstance(freshness, StaleBundle)
+ assert freshness.age == timedelta(days=8, hours=3)
+ assert format_age(freshness.age) == "8d3h"
+ assert freshness.limit == MAX_BUNDLE_AGE
+
+ def test_naive_recorded_at_is_read_as_utc(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ write_manifest(root, (NOW - timedelta(days=1)).replace(tzinfo=None))
+ assert isinstance(check_freshness(root, now=NOW), FreshBundle)
+
+ def test_missing_manifest_is_unreadable_with_recording_hint(self, tmp_path: Path) -> None:
+ freshness = check_freshness(tmp_path / "absent", now=NOW)
+ assert isinstance(freshness, UnreadableBundle)
+ assert MANIFEST_FILENAME in freshness.reason
+ assert "E2E_FIXTURE_MODE=record" in freshness.reason
+
+ def test_corrupt_manifest_is_unreadable(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ root.mkdir()
+ (root / MANIFEST_FILENAME).write_text("{not json", encoding="utf-8")
+ assert isinstance(check_freshness(root, now=NOW), UnreadableBundle)
+
+ def test_unknown_format_version_is_unreadable(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ write_manifest(root, NOW, format_version=BUNDLE_FORMAT_VERSION + 1)
+ freshness = check_freshness(root, now=NOW)
+ assert isinstance(freshness, UnreadableBundle)
+ assert f"format_version {BUNDLE_FORMAT_VERSION + 1}" in freshness.reason
+
+
+class TestPrepareBundle:
+ def test_fresh_directory_gets_a_fresh_manifest(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ prepared(root)
+ freshness = check_freshness(root, now=datetime.now(timezone.utc))
+ assert isinstance(freshness, FreshBundle)
+ assert freshness.manifest.format_version == BUNDLE_FORMAT_VERSION
+ assert freshness.manifest.harness_version
+
+ def test_record_wipes_the_previous_bundle_instead_of_reading_it(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ prepared(root).record(
+ test_key="old.py::test_old",
+ request=plain_request("/stale"),
+ response=RecordedResult(kind="unauthorized"),
+ )
+ assert any(entry.is_dir() for entry in root.iterdir())
+ prepared(root)
+ assert {entry.name for entry in root.iterdir()} == {MANIFEST_FILENAME}
+
+ def test_refuses_to_wipe_a_directory_that_is_not_a_bundle(self, tmp_path: Path) -> None:
+ root = tmp_path / "precious"
+ root.mkdir()
+ (root / "notes.txt").write_text("keep me", encoding="utf-8")
+ outcome = prepare_bundle(root)
+ assert isinstance(outcome, UnsafeBundleDir)
+ assert MANIFEST_FILENAME in outcome.reason
+ assert (root / "notes.txt").read_text(encoding="utf-8") == "keep me"
+
+ def test_refuses_a_path_that_is_a_file(self, tmp_path: Path) -> None:
+ target = tmp_path / "not-a-dir"
+ target.write_text("x", encoding="utf-8")
+ outcome = prepare_bundle(target)
+ assert isinstance(outcome, UnsafeBundleDir)
+ assert "not a directory" in outcome.reason
+
+
+class TestSlugs:
+ def test_slug_for_test_is_deterministic(self) -> None:
+ key = "tests/e2e/suite/test_mod.py::TestX::test_case"
+ assert slug_for_test(key) == slug_for_test(key)
+
+ def test_same_tail_in_different_files_never_collides(self) -> None:
+ first = slug_for_test("tests/e2e/a/test_a.py::test_case")
+ second = slug_for_test("tests/e2e/b/test_b.py::test_case")
+ assert first != second
+ assert first.startswith("test_case-")
+ assert second.startswith("test_case-")
+
+ def test_interaction_filename_orders_and_slugs(self) -> None:
+ request = RecordedRequest(method="post", path="/chat/completions", headers={})
+ assert interaction_filename(3, request) == "0003-post-chat-completions.json"
+
+
+class TestRecordAndLoad:
+ def test_load_returns_interactions_in_recorded_order(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ recorder = prepared(root)
+ key = "suite/test_mod.py::test_ordered"
+ for path in ("/first", "/second", "/third"):
+ recorder.record(
+ test_key=key,
+ request=plain_request(path),
+ response=RecordedResult(kind="unauthorized"),
+ )
+ loaded = load_bundle(root)
+ assert isinstance(loaded, LoadedBundle)
+ assert [
+ interaction.request.path for interaction in loaded.interactions[slug_for_test(key)]
+ ] == ["/first", "/second", "/third"]
+
+ def test_interactions_group_per_test(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ recorder = prepared(root)
+ for key in ("suite/test_a.py::test_one", "suite/test_b.py::test_two"):
+ recorder.record(
+ test_key=key,
+ request=plain_request(f"/{key[-3:]}"),
+ response=RecordedResult(kind="unauthorized"),
+ )
+ loaded = load_bundle(root)
+ assert isinstance(loaded, LoadedBundle)
+ assert set(loaded.interactions) == {
+ slug_for_test("suite/test_a.py::test_one"),
+ slug_for_test("suite/test_b.py::test_two"),
+ }
diff --git a/tests/e2e/test_fixture_transport.py b/tests/e2e/test_fixture_transport.py
new file mode 100644
index 00000000000..6ffcdaeb95f
--- /dev/null
+++ b/tests/e2e/test_fixture_transport.py
@@ -0,0 +1,438 @@
+"""Harness coverage for the record/replay transports (LIT-5729).
+
+No proxy and no ``e2e`` marker. A fake in-memory ``Transport`` stands in for
+the live one (dependency injection, no monkeypatching): recording must pass
+every value through unchanged while writing one redacted interaction file per
+call, and replay must serve identical values from the bundle alone - the
+fake's call log proves nothing reaches the inner transport - failing hard
+(``ReplayMiss``) on any drift in order, verb, or path. The collection-time
+gate and report header are pinned here too, including the stale message that
+names the bundle's age.
+"""
+
+from __future__ import annotations
+
+import hashlib
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+import pytest
+from pydantic import BaseModel
+
+from e2e_http import (
+ AuthHeaders,
+ BinaryStream,
+ ProbeResult,
+ Result,
+ StreamingResponse,
+ Success,
+)
+from fixture_bundle import (
+ BUNDLE_FORMAT_VERSION,
+ MANIFEST_FILENAME,
+ BundleRecorder,
+ Interaction,
+ LoadedBundle,
+ Manifest,
+ load_bundle,
+ prepare_bundle,
+ slug_for_test,
+)
+from fixture_transport import (
+ InvalidFixtureMode,
+ RecordingTransport,
+ ReplayMiss,
+ ReplaySource,
+ ReplayTransport,
+ current_test_key,
+ deterministic_marker,
+ fixture_mode_collection_error,
+ fixture_report_lines,
+ parse_fixture_mode,
+ select_transport,
+)
+from transport import Transport
+
+NOW = datetime(2026, 8, 18, 12, 0, 0, tzinfo=timezone.utc)
+
+
+class Payload(BaseModel):
+ value: str
+
+
+class Body(BaseModel):
+ prompt: str
+
+
+class Query(BaseModel):
+ q: str
+
+
+STREAMING = StreamingResponse(
+ status_code=200,
+ body="",
+ content_type="text/event-stream",
+ chunks=2,
+ stream_events=["one", "two"],
+ stream_done=True,
+)
+BINARY = BinaryStream(status_code=200, content_type="audio/mpeg", chunk_count=3, total_bytes=42)
+PROBE = ProbeResult(status_code=200, body="alive")
+
+
+@dataclass
+class FakeTransport:
+ calls: list[str] = field(default_factory=list)
+
+ def bearer(self, key: str) -> AuthHeaders:
+ return AuthHeaders(authorization=f"Bearer {key}")
+
+ @property
+ def master(self) -> AuthHeaders:
+ return self.bearer("sk-fake-master")
+
+ def _success[R: BaseModel](self, response_type: type[R]) -> Result[R]:
+ return Success(status_code=200, data=response_type.model_validate({"value": "live"}))
+
+ def post[R: BaseModel](
+ self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
+ ) -> Result[R]:
+ self.calls.append(f"post {path}")
+ return self._success(response_type)
+
+ def get[R: BaseModel](
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ params: BaseModel,
+ response_type: type[R],
+ timeout: float | None = None,
+ ) -> Result[R]:
+ self.calls.append(f"get {path}")
+ return self._success(response_type)
+
+ def delete[R: BaseModel](
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ json: BaseModel,
+ response_type: type[R],
+ params: BaseModel | None = None,
+ ) -> Result[R]:
+ self.calls.append(f"delete {path}")
+ return self._success(response_type)
+
+ def patch[R: BaseModel](
+ self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
+ ) -> Result[R]:
+ self.calls.append(f"patch {path}")
+ return self._success(response_type)
+
+ def put[R: BaseModel](
+ self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
+ ) -> Result[R]:
+ self.calls.append(f"put {path}")
+ return self._success(response_type)
+
+ def stream(self, path: str, *, headers: BaseModel, json: BaseModel) -> StreamingResponse:
+ self.calls.append(f"stream {path}")
+ return STREAMING
+
+ def stream_binary(
+ self, path: str, *, headers: BaseModel, json: BaseModel, chunk_size: int = 8192
+ ) -> BinaryStream:
+ self.calls.append(f"stream_binary {path}")
+ return BINARY
+
+ def send(
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ json: BaseModel,
+ params: BaseModel | None = None,
+ stream: bool = False,
+ ) -> StreamingResponse:
+ self.calls.append(f"send {path}")
+ return STREAMING
+
+ def probe(self, path: str, *, params: BaseModel) -> ProbeResult:
+ self.calls.append(f"probe {path}")
+ return PROBE
+
+ def upload[R: BaseModel](
+ self,
+ path: str,
+ *,
+ headers: BaseModel,
+ form: BaseModel,
+ filename: str,
+ content: bytes,
+ file_content_type: str = "application/jsonl",
+ file_field: str = "file",
+ params: BaseModel | None = None,
+ response_type: type[R],
+ ) -> Result[R]:
+ self.calls.append(f"upload {path}")
+ return self._success(response_type)
+
+ def download(self, path: str, *, headers: BaseModel) -> StreamingResponse:
+ self.calls.append(f"download {path}")
+ return STREAMING
+
+
+def make_recorder(root: Path) -> BundleRecorder:
+ recorder = prepare_bundle(root)
+ assert isinstance(recorder, BundleRecorder)
+ return recorder
+
+
+def replay_source(root: Path) -> ReplaySource:
+ loaded = load_bundle(root)
+ assert isinstance(loaded, LoadedBundle)
+ return ReplaySource(bundle=loaded)
+
+
+def this_tests_files(root: Path) -> list[Path]:
+ slug_dir = root / slug_for_test(current_test_key())
+ return sorted(slug_dir.glob("*.json")) if slug_dir.is_dir() else []
+
+
+def write_manifest(root: Path, recorded_at: datetime) -> None:
+ root.mkdir(parents=True, exist_ok=True)
+ manifest = Manifest(
+ format_version=BUNDLE_FORMAT_VERSION, recorded_at=recorded_at, harness_version="abc1234"
+ )
+ (root / MANIFEST_FILENAME).write_text(manifest.model_dump_json(), encoding="utf-8")
+
+
+class TestParseFixtureMode:
+ @pytest.mark.parametrize(
+ ("raw", "expected"),
+ [("live", "live"), ("record", "record"), ("replay", "replay"), ("", "live"), (" REPLAY ", "replay")],
+ )
+ def test_known_values_normalize(self, raw: str, expected: str) -> None:
+ assert parse_fixture_mode(raw) == expected
+
+ def test_unknown_value_is_invalid_with_the_original_spelling(self) -> None:
+ assert parse_fixture_mode("cached") == InvalidFixtureMode(value="cached")
+
+
+class TestDeterministicMarker:
+ def test_sequence_is_a_pure_function_of_test_and_ordinal(self) -> None:
+ """A replay process must regenerate exactly the markers the record
+ process generated, so the Nth marker of a test is pinned to a pure
+ function of the node id and N."""
+ key = current_test_key()
+ assert deterministic_marker() == hashlib.sha1(f"{key}#0".encode()).hexdigest()[:12]
+ assert deterministic_marker() == hashlib.sha1(f"{key}#1".encode()).hexdigest()[:12]
+
+
+class TestCurrentTestKey:
+ def test_names_this_test_and_strips_the_phase(self) -> None:
+ key = current_test_key()
+ assert key.endswith("TestCurrentTestKey::test_names_this_test_and_strips_the_phase")
+ assert "(call)" not in key
+
+
+class TestRecordingTransport:
+ def test_passes_the_result_through_and_writes_one_file_per_call(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ recording: Transport = RecordingTransport(inner=fake, recorder=make_recorder(root))
+ result = recording.post(
+ "/model/new", headers=fake.master, json=Body(prompt="x"), response_type=Payload
+ )
+ assert result == Success(status_code=200, data=Payload(value="live"))
+ assert fake.calls == ["post /model/new"]
+ files = this_tests_files(root)
+ assert [file.name for file in files] == ["0000-post-model-new.json"]
+ interaction = Interaction.model_validate_json(files[0].read_text(encoding="utf-8"))
+ assert interaction.request.method == "post"
+ assert interaction.request.path == "/model/new"
+
+ def test_redacts_auth_header_values_in_the_recorded_request(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ recording: Transport = RecordingTransport(inner=fake, recorder=make_recorder(root))
+ headers = AuthHeaders.model_validate(
+ {"authorization": "Bearer sk-secret", "x-litellm-api-key": "sk-other"}
+ )
+ recording.post("/key/generate", headers=headers, json=Body(prompt="x"), response_type=Payload)
+ interaction = Interaction.model_validate_json(
+ this_tests_files(root)[0].read_text(encoding="utf-8")
+ )
+ assert interaction.request.headers == {
+ "authorization": "",
+ "x-litellm-api-key": "",
+ }
+ assert "sk-secret" not in this_tests_files(root)[0].read_text(encoding="utf-8")
+
+ def test_upload_records_a_content_digest_not_the_bytes(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ recording: Transport = RecordingTransport(inner=fake, recorder=make_recorder(root))
+ recording.upload(
+ "/v1/files",
+ headers=fake.master,
+ form=Query(q="batch"),
+ filename="batch.jsonl",
+ content=b'{"custom_id": "1"}',
+ response_type=Payload,
+ )
+ interaction = Interaction.model_validate_json(
+ this_tests_files(root)[0].read_text(encoding="utf-8")
+ )
+ assert interaction.request.file_name == "batch.jsonl"
+ assert interaction.request.file_bytes == len(b'{"custom_id": "1"}')
+ assert interaction.request.file_sha256 is not None
+ assert "custom_id" not in interaction.request.model_dump_json()
+
+
+class TestReplayTransport:
+ def test_serves_recorded_values_without_touching_the_inner_transport(
+ self, tmp_path: Path
+ ) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ recording: Transport = RecordingTransport(inner=fake, recorder=make_recorder(root))
+ recorded_post = recording.post(
+ "/model/new", headers=fake.master, json=Body(prompt="x"), response_type=Payload
+ )
+ recorded_get = recording.get(
+ "/v1/models", headers=fake.master, params=Query(q="all"), response_type=Payload
+ )
+ recorded_stream = recording.stream(
+ "/chat/completions", headers=fake.master, json=Body(prompt="hi")
+ )
+ recorded_probe = recording.probe("/health/liveliness", params=Query(q="1"))
+ recorded_binary = recording.stream_binary(
+ "/v1/audio/speech", headers=fake.master, json=Body(prompt="say")
+ )
+ calls_after_record = list(fake.calls)
+
+ replay: Transport = ReplayTransport(source=replay_source(root), master_key="sk-1234")
+ assert (
+ replay.post("/model/new", headers=replay.master, json=Body(prompt="x"), response_type=Payload)
+ == recorded_post
+ )
+ assert (
+ replay.get("/v1/models", headers=replay.master, params=Query(q="all"), response_type=Payload)
+ == recorded_get
+ )
+ assert (
+ replay.stream("/chat/completions", headers=replay.master, json=Body(prompt="hi"))
+ == recorded_stream
+ )
+ assert replay.probe("/health/liveliness", params=Query(q="1")) == recorded_probe
+ assert (
+ replay.stream_binary("/v1/audio/speech", headers=replay.master, json=Body(prompt="say"))
+ == recorded_binary
+ )
+ assert fake.calls == calls_after_record
+
+ def test_mismatched_call_names_recorded_and_actual(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ recording: Transport = RecordingTransport(inner=fake, recorder=make_recorder(root))
+ recording.post("/model/new", headers=fake.master, json=Body(prompt="x"), response_type=Payload)
+ replay: Transport = ReplayTransport(source=replay_source(root), master_key="sk-1234")
+ with pytest.raises(ReplayMiss, match=r"recorded post /model/new, test made get /v1/models"):
+ replay.get("/v1/models", headers=replay.master, params=Query(q="all"), response_type=Payload)
+
+ def test_exhausted_recording_names_the_call_count(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ recording: Transport = RecordingTransport(inner=fake, recorder=make_recorder(root))
+ recording.post("/model/new", headers=fake.master, json=Body(prompt="x"), response_type=Payload)
+ replay: Transport = ReplayTransport(source=replay_source(root), master_key="sk-1234")
+ replay.post("/model/new", headers=replay.master, json=Body(prompt="x"), response_type=Payload)
+ with pytest.raises(ReplayMiss, match=r"call #2 \(post /model/new\) has no recorded interaction \(1 recorded"):
+ replay.post("/model/new", headers=replay.master, json=Body(prompt="x"), response_type=Payload)
+
+
+class TestSelectTransport:
+ def test_live_returns_the_live_transport_untouched(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ for mode_raw in ("live", ""):
+ assert (
+ select_transport(fake, mode_raw=mode_raw, bundle_dir=tmp_path / "b", master_key="sk")
+ is fake
+ )
+
+ def test_record_wraps_live_and_starts_a_fresh_bundle(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ write_manifest(root, NOW - timedelta(days=30))
+ (root / "old-test-slug").mkdir()
+ (root / "old-test-slug" / "0000-post-old.json").write_text("{}", encoding="utf-8")
+ selected = select_transport(fake, mode_raw="record", bundle_dir=root, master_key="sk")
+ assert isinstance(selected, RecordingTransport)
+ assert selected.inner is fake
+ assert {entry.name for entry in root.iterdir()} == {MANIFEST_FILENAME}
+
+ def test_replay_builds_a_transport_from_the_bundle_alone(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ make_recorder(root)
+ selected = select_transport(fake, mode_raw="replay", bundle_dir=root, master_key="sk-master")
+ assert isinstance(selected, ReplayTransport)
+ assert selected.master == AuthHeaders(authorization="Bearer sk-master")
+
+ def test_invalid_mode_raises_naming_the_value(self, tmp_path: Path) -> None:
+ with pytest.raises(ValueError, match="cached"):
+ select_transport(
+ FakeTransport(), mode_raw="cached", bundle_dir=tmp_path / "b", master_key="sk"
+ )
+
+
+class TestCollectionGate:
+ def test_invalid_mode_names_the_value_and_the_choices(self, tmp_path: Path) -> None:
+ assert (
+ fixture_mode_collection_error("cached", tmp_path, now=NOW)
+ == "E2E_FIXTURE_MODE='cached' is not one of live, record, replay"
+ )
+
+ @pytest.mark.parametrize("mode_raw", ["live", "", "record"])
+ def test_live_and_record_never_block_collection(self, mode_raw: str, tmp_path: Path) -> None:
+ assert fixture_mode_collection_error(mode_raw, tmp_path / "missing", now=NOW) is None
+
+ def test_replay_with_no_bundle_says_how_to_record_one(self, tmp_path: Path) -> None:
+ reason = fixture_mode_collection_error("replay", tmp_path / "missing", now=NOW)
+ assert reason is not None
+ assert f"no {MANIFEST_FILENAME}" in reason
+ assert "E2E_FIXTURE_MODE=record" in reason
+
+ def test_stale_replay_bundle_fails_naming_its_age(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ write_manifest(root, NOW - timedelta(days=9, hours=5))
+ reason = fixture_mode_collection_error("replay", root, now=NOW)
+ assert reason is not None
+ assert "age 9d5h exceeds the 7-day limit" in reason
+ assert "re-record with E2E_FIXTURE_MODE=record" in reason
+
+ def test_fresh_replay_bundle_collects(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ write_manifest(root, NOW - timedelta(days=2))
+ assert fixture_mode_collection_error("replay", root, now=NOW) is None
+
+
+class TestReportHeader:
+ def test_live_mode_prints_nothing(self, tmp_path: Path) -> None:
+ assert fixture_report_lines("live", tmp_path, now=NOW) == []
+ assert fixture_report_lines("", tmp_path, now=NOW) == []
+
+ def test_record_and_replay_name_the_bundle(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ recorded_at = NOW - timedelta(days=1)
+ write_manifest(root, recorded_at)
+ assert fixture_report_lines("record", root, now=NOW) == [
+ f"e2e fixture mode: record -> {root}"
+ ]
+ replay_lines = fixture_report_lines("replay", root, now=NOW)
+ assert len(replay_lines) == 1
+ assert "replay" in replay_lines[0]
+ assert recorded_at.isoformat() in replay_lines[0]
From 6bf535bb8f99754bd9524da840ad7b350047363f Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Tue, 18 Aug 2026 14:34:23 -0700
Subject: [PATCH 19/88] feat(e2e): fail passed replays that leave recorded
interactions unconsumed
---
tests/e2e/CLAUDE.md | 2 +-
tests/e2e/conftest.py | 34 ++++++++++++++++++++-
tests/e2e/fixture_transport.py | 24 +++++++++++++++
tests/e2e/test_fixture_transport.py | 47 +++++++++++++++++++++++++++++
4 files changed, 105 insertions(+), 2 deletions(-)
diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md
index 05753c736de..9969ed10308 100644
--- a/tests/e2e/CLAUDE.md
+++ b/tests/e2e/CLAUDE.md
@@ -77,7 +77,7 @@ Mark live tests with `@pytest.mark.e2e` (on the class or the module). Pure cover
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; 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
+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)
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index 6b27bb459a5..da2a7da0bfa 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -15,7 +15,7 @@ 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
@@ -27,6 +27,7 @@ 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
@@ -34,6 +35,7 @@ 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:
@@ -134,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/fixture_transport.py b/tests/e2e/fixture_transport.py
index 756362cf29e..b99b0d2d80d 100644
--- a/tests/e2e/fixture_transport.py
+++ b/tests/e2e/fixture_transport.py
@@ -332,6 +332,21 @@ class ReplaySource:
self._cursors[slug] = index + 1
return interaction
+ def leftover_error(self, test_key: str) -> str | None:
+ """Non-None when the test consumed fewer interactions than were recorded,
+ meaning a passing replay proved less than the bundle claims."""
+ slug = slug_for_test(test_key)
+ recorded = self.bundle.interactions.get(slug, ())
+ consumed = self._cursors.get(slug, 0)
+ if consumed >= len(recorded):
+ return None
+ pending = recorded[consumed]
+ return (
+ f"replay incomplete for {test_key}: {len(recorded) - consumed} of {len(recorded)} recorded "
+ f"interactions never consumed, next is {pending.request.method} {pending.request.path}; "
+ "re-record with E2E_FIXTURE_MODE=record"
+ )
+
def _expect_result(interaction: Interaction) -> RecordedResult:
match interaction.response:
@@ -474,6 +489,15 @@ def _shared_replay_source(root: Path) -> ReplaySource:
return ReplaySource(bundle=loaded)
+def replay_leftover_error(*, mode_raw: str, bundle_dir: Path, test_key: str) -> str | None:
+ """Teardown-time completeness check: in replay mode a passed test with
+ unconsumed recorded interactions must fail instead of passing against a
+ recording it no longer matches. Inert in every other mode."""
+ if parse_fixture_mode(mode_raw) != "replay":
+ return None
+ return _shared_replay_source(bundle_dir).leftover_error(test_key)
+
+
def select_transport(
live: Transport, *, mode_raw: str, bundle_dir: Path, master_key: str
) -> Transport:
diff --git a/tests/e2e/test_fixture_transport.py b/tests/e2e/test_fixture_transport.py
index 6ffcdaeb95f..5c7201cca37 100644
--- a/tests/e2e/test_fixture_transport.py
+++ b/tests/e2e/test_fixture_transport.py
@@ -50,6 +50,7 @@ from fixture_transport import (
fixture_mode_collection_error,
fixture_report_lines,
parse_fixture_mode,
+ replay_leftover_error,
select_transport,
)
from transport import Transport
@@ -354,6 +355,52 @@ class TestReplayTransport:
replay.post("/model/new", headers=replay.master, json=Body(prompt="x"), response_type=Payload)
+class TestReplayLeftover:
+ def test_fully_consumed_recording_leaves_nothing(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ recording: Transport = RecordingTransport(inner=fake, recorder=make_recorder(root))
+ recording.post("/model/new", headers=fake.master, json=Body(prompt="x"), response_type=Payload)
+ source = replay_source(root)
+ replay: Transport = ReplayTransport(source=source, master_key="sk-1234")
+ replay.post("/model/new", headers=replay.master, json=Body(prompt="x"), response_type=Payload)
+ assert source.leftover_error(current_test_key()) is None
+
+ def test_unconsumed_trailing_interactions_name_the_next_call(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ recording: Transport = RecordingTransport(inner=fake, recorder=make_recorder(root))
+ recording.post("/model/new", headers=fake.master, json=Body(prompt="x"), response_type=Payload)
+ recording.probe("/health/liveliness", params=Query(q="1"))
+ source = replay_source(root)
+ replay: Transport = ReplayTransport(source=source, master_key="sk-1234")
+ replay.post("/model/new", headers=replay.master, json=Body(prompt="x"), response_type=Payload)
+ error = source.leftover_error(current_test_key())
+ assert error is not None
+ assert "1 of 2 recorded interactions never consumed" in error
+ assert "next is probe /health/liveliness" in error
+ assert "re-record with E2E_FIXTURE_MODE=record" in error
+
+ def test_test_without_recordings_has_no_leftover(self, tmp_path: Path) -> None:
+ root = tmp_path / "bundle"
+ make_recorder(root)
+ assert replay_source(root).leftover_error("suite.py::test_never_recorded") is None
+
+ def test_inert_outside_replay_mode(self, tmp_path: Path) -> None:
+ missing = tmp_path / "missing"
+ assert replay_leftover_error(mode_raw="", bundle_dir=missing, test_key="k") is None
+ assert replay_leftover_error(mode_raw="record", bundle_dir=missing, test_key="k") is None
+
+ def test_replay_mode_reads_the_shared_bundle(self, tmp_path: Path) -> None:
+ fake = FakeTransport()
+ root = tmp_path / "bundle"
+ recording: Transport = RecordingTransport(inner=fake, recorder=make_recorder(root))
+ recording.post("/model/new", headers=fake.master, json=Body(prompt="x"), response_type=Payload)
+ error = replay_leftover_error(mode_raw="replay", bundle_dir=root, test_key=current_test_key())
+ assert error is not None
+ assert "1 of 1 recorded interactions never consumed" in error
+
+
class TestSelectTransport:
def test_live_returns_the_live_transport_untouched(self, tmp_path: Path) -> None:
fake = FakeTransport()
From 17b72d5089c7ba13c23e45837fd06a28825a82e8 Mon Sep 17 00:00:00 2001
From: yassin
Date: Tue, 18 Aug 2026 23:26:41 +0000
Subject: [PATCH 20/88] fix(search): send MCP-Protocol-Version on AgentCore
gateway calls
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/bedrock/search/transformation.py | 9 ++++-
.../test_agentcore_search_transformation.py | 39 ++++++++++++++++++-
2 files changed, 45 insertions(+), 3 deletions(-)
diff --git a/litellm/llms/bedrock/search/transformation.py b/litellm/llms/bedrock/search/transformation.py
index ca9759ed151..5567dc8403e 100644
--- a/litellm/llms/bedrock/search/transformation.py
+++ b/litellm/llms/bedrock/search/transformation.py
@@ -66,6 +66,11 @@ AGENTCORE_DEFAULT_TOOL_NAME: Final = "web-search-tool___WebSearch"
# with the proxy's credentials.
AGENTCORE_TOOL_NAME_SUFFIX: Final = "___WebSearch"
+# MCP revision this provider speaks. Sent on every request because the gateway is
+# called statelessly, without an initialize handshake to negotiate a version;
+# servers that predate the header ignore it.
+AGENTCORE_MCP_PROTOCOL_VERSION: Final = "2025-06-18"
+
_GATEWAY_REGION_PATTERN: Final = re.compile(r"\.gateway\.bedrock-agentcore\.([a-z0-9-]+)\.amazonaws\.com")
_SSE_EVENT_SEPARATOR: Final = re.compile(r"\n[ \t]*\n")
@@ -147,7 +152,8 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
) -> dict: # mutable-ok: the handler passes these headers straight to httpx, which wants a dict
"""
Set MCP transport headers. Per the MCP Streamable HTTP transport spec,
- the client MUST accept both application/json and text/event-stream.
+ the client MUST accept both application/json and text/event-stream, and
+ declare its protocol revision with MCP-Protocol-Version.
Authentication itself happens in sign_request(): bearer token for
CUSTOM_JWT gateways, AWS SigV4 for AWS_IAM gateways.
@@ -156,6 +162,7 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
**headers,
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
+ "MCP-Protocol-Version": AGENTCORE_MCP_PROTOCOL_VERSION,
}
def get_complete_url(
diff --git a/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py b/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py
index 38189abe0e8..63ec2286c3d 100644
--- a/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py
+++ b/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py
@@ -13,7 +13,10 @@ import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import litellm
-from litellm.llms.bedrock.search.transformation import AgentCoreSearchConfig
+from litellm.llms.bedrock.search.transformation import (
+ AGENTCORE_MCP_PROTOCOL_VERSION,
+ AgentCoreSearchConfig,
+)
GATEWAY_URL = "https://testgateway-abc123.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp"
@@ -148,11 +151,43 @@ class TestAgentCoreSearch:
assert config.get_complete_url(api_base=GATEWAY_URL, optional_params={}) == GATEWAY_URL
def test_validate_environment_sets_mcp_headers(self):
- """MCP Streamable HTTP requires accepting both JSON and SSE."""
+ """MCP Streamable HTTP requires accepting both JSON and SSE, and declaring
+ the protocol revision the client speaks."""
config = AgentCoreSearchConfig()
headers = config.validate_environment(headers={})
assert headers["Accept"] == "application/json, text/event-stream"
assert headers["Content-Type"] == "application/json"
+ assert headers["MCP-Protocol-Version"] == AGENTCORE_MCP_PROTOCOL_VERSION
+
+ def test_protocol_version_header_survives_signing(self):
+ """Both auth paths must keep the MCP-Protocol-Version header on the wire."""
+ config = AgentCoreSearchConfig()
+ headers = config.validate_environment(headers={})
+
+ bearer_headers, _ = config.sign_request(
+ headers=headers,
+ optional_params={},
+ request_data={"jsonrpc": "2.0"},
+ api_base=GATEWAY_URL,
+ api_key="test-jwt-token",
+ )
+ assert bearer_headers["MCP-Protocol-Version"] == AGENTCORE_MCP_PROTOCOL_VERSION
+
+ with patch.dict(
+ os.environ,
+ {
+ "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE",
+ "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ },
+ ):
+ signed_headers, _ = config.sign_request(
+ headers=headers,
+ optional_params={"aws_region_name": "us-east-1"},
+ request_data={"jsonrpc": "2.0"},
+ api_base=GATEWAY_URL,
+ )
+ assert signed_headers["Authorization"].startswith("AWS4-HMAC-SHA256")
+ assert signed_headers["MCP-Protocol-Version"] == AGENTCORE_MCP_PROTOCOL_VERSION
def test_transform_search_response_parses_sse_frame(self):
"""Gateway may answer with an SSE-framed JSON-RPC message."""
From 608d7499836c1aaa7fe752c473c68d5813b9f29b Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Tue, 18 Aug 2026 20:46:59 -0700
Subject: [PATCH 21/88] fix(batches): stop one bad output line from zeroing an
entire batch's spend
---
litellm/batches/batch_utils.py | 161 ++++++++++++------
.../test_litellm/batches/test_batch_utils.py | 40 +++--
.../proxy/hooks/test_batch_file_validation.py | 12 +-
type-discipline-budget.json | 2 +-
4 files changed, 149 insertions(+), 66 deletions(-)
diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py
index c2cbb9604e5..feb84ccd8a6 100644
--- a/litellm/batches/batch_utils.py
+++ b/litellm/batches/batch_utils.py
@@ -1,5 +1,5 @@
import json
-from collections.abc import Iterable, Iterator
+from collections.abc import Iterable, Iterator, Mapping
from dataclasses import dataclass
from typing import Any, Final, Literal
@@ -87,7 +87,7 @@ async def _handle_completed_batch(
return batch_cost, batch_usage, [model_name]
return _aggregate_batch_cost_usage_models(
- entries=_iter_batch_input_entries(file_content),
+ entries=_iter_batch_output_entries(file_content),
custom_llm_provider=custom_llm_provider,
model_name=model_name,
model_info=model_info,
@@ -111,43 +111,91 @@ def _iter_successful_output_line_stats(
model_name: str | None,
model_info: ModelInfo | None,
) -> Iterator[_BatchOutputLineStats]:
+ for entry in entries:
+ stats = _safe_output_line_stats(entry, custom_llm_provider, model_name, model_info)
+ if stats is not None:
+ yield stats
+
+
+def _safe_output_line_stats(
+ entry: Mapping,
+ custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"],
+ model_name: str | None,
+ model_info: ModelInfo | None,
+) -> _BatchOutputLineStats | None:
+ """Return the stats for one batch output line, or None for a line that is
+ unsuccessful or cannot be costed, so a single bad line never aborts the
+ whole batch's cost accounting."""
+ custom_id: Final = entry.get("custom_id") if isinstance(entry, dict) else None
+ try:
+ if not _batch_response_was_successful(entry, custom_llm_provider):
+ return None
+ return _compute_output_line_stats(entry, custom_llm_provider, model_name, model_info)
+ except Exception as e: # noqa: BLE001 # any single line's costing failure must not abort the whole batch
+ verbose_logger.warning(
+ "batch output line could not be costed, so it is billed at $0 and the rest of the batch "
+ "is still billed. custom_id=%s error=%s",
+ custom_id,
+ str(e),
+ )
+ return None
+
+
+def _compute_output_line_stats(
+ entry: Mapping,
+ custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"],
+ model_name: str | None,
+ model_info: ModelInfo | None,
+) -> _BatchOutputLineStats:
+ response_body: Final = _get_response_from_batch_job_output_file(entry, custom_llm_provider)
+ usage: Final = _get_batch_job_usage_from_response_body(response_body, custom_llm_provider)
+ prompt_details: Final = parse_prompt_tokens_details(usage)
+ raw_model: Final = response_body.get("model")
+ response_model: Final = raw_model if isinstance(raw_model, str) and raw_model else None
+ return _BatchOutputLineStats(
+ cost=_output_line_cost(
+ response_body=response_body,
+ usage=usage,
+ custom_llm_provider=custom_llm_provider,
+ model_name=model_name,
+ response_model=response_model,
+ model_info=model_info,
+ ),
+ prompt_tokens=usage.prompt_tokens,
+ completion_tokens=usage.completion_tokens,
+ total_tokens=usage.total_tokens,
+ cache_read_tokens=prompt_details["cache_hit_tokens"],
+ cache_creation_tokens=prompt_details["cache_creation_tokens"],
+ model=response_model,
+ )
+
+
+def _output_line_cost(
+ response_body: Mapping,
+ usage: Usage,
+ custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"],
+ model_name: str | None,
+ response_model: str | None,
+ model_info: ModelInfo | None,
+) -> float:
from litellm.cost_calculator import batch_cost_calculator
- for entry in entries:
- if not _batch_response_was_successful(entry, custom_llm_provider):
- continue
- response_body = _get_response_from_batch_job_output_file(entry, custom_llm_provider)
- usage = _get_batch_job_usage_from_response_body(response_body, custom_llm_provider)
- prompt_details = parse_prompt_tokens_details(usage)
- raw_model = response_body.get("model")
- response_model = raw_model if isinstance(raw_model, str) and raw_model else None
- if model_info is not None or custom_llm_provider in ("anthropic", "bedrock"):
- if custom_llm_provider == "bedrock" and model_name:
- cost_model = model_name
- else:
- cost_model = response_model or model_name or ""
- prompt_cost, completion_cost = batch_cost_calculator(
- usage=usage,
- model=cost_model,
- custom_llm_provider=custom_llm_provider,
- model_info=model_info,
- )
- line_cost = prompt_cost + completion_cost
- else:
- line_cost = litellm.completion_cost(
- completion_response=response_body,
- custom_llm_provider=custom_llm_provider,
- call_type=CallTypes.aretrieve_batch.value,
- )
- yield _BatchOutputLineStats(
- cost=line_cost,
- prompt_tokens=usage.prompt_tokens,
- completion_tokens=usage.completion_tokens,
- total_tokens=usage.total_tokens,
- cache_read_tokens=prompt_details["cache_hit_tokens"],
- cache_creation_tokens=prompt_details["cache_creation_tokens"],
- model=response_model,
+ if model_info is None and custom_llm_provider not in ("anthropic", "bedrock"):
+ return litellm.completion_cost(
+ completion_response=response_body,
+ custom_llm_provider=custom_llm_provider,
+ call_type=CallTypes.aretrieve_batch.value,
)
+ cost_model: Final = (
+ model_name if custom_llm_provider == "bedrock" and model_name else response_model or model_name or ""
+ )
+ prompt_cost, completion_cost = batch_cost_calculator(
+ usage=usage,
+ model=cost_model,
+ custom_llm_provider=custom_llm_provider,
+ model_info=model_info,
+ )
+ return prompt_cost + completion_cost
def _aggregate_batch_cost_usage_models(
@@ -338,9 +386,10 @@ def _extract_file_access_credentials(litellm_params: dict | None) -> dict:
def _get_file_content_as_dictionary(file_content: bytes) -> list[dict]:
"""
- Get the file content as a list of dictionaries from JSON Lines format
+ Get the file content as a list of dictionaries from JSON Lines format,
+ skipping malformed lines
"""
- return list(_iter_batch_input_entries(file_content))
+ return list(_iter_batch_output_entries(file_content))
def _iter_batch_input_lines(file_content: bytes) -> Iterator[bytes]:
@@ -361,15 +410,29 @@ def _iter_batch_input_lines(file_content: bytes) -> Iterator[bytes]:
yield line
-def _iter_batch_input_entries(file_content: bytes) -> Iterator[dict]:
+def _iter_batch_output_entries(file_content: bytes) -> Iterator[dict]:
"""
- Yield parsed batch input JSONL entries one at a time without materializing the
- whole file as a list, so peak memory stays bounded. Raises on a malformed line;
- callers that must survive bad rows should iterate ``_iter_batch_input_lines``
- and parse per-row instead.
+ Yield parsed batch output JSONL entries one at a time without materializing
+ the whole file as a list, so peak memory stays bounded. A malformed or
+ non-object line is skipped with a warning so one bad line never aborts the
+ whole batch's cost accounting.
"""
for line in _iter_batch_input_lines(file_content):
- yield json.loads(line)
+ entry = _parse_batch_output_line(line)
+ if entry is not None:
+ yield entry
+
+
+def _parse_batch_output_line(line: bytes) -> dict | None:
+ try:
+ parsed: Final = json.loads(line)
+ except json.JSONDecodeError as e:
+ verbose_logger.warning("skipping malformed batch output line: %s", str(e))
+ return None
+ if isinstance(parsed, dict):
+ return parsed
+ verbose_logger.warning("skipping non-object batch output line of type %s", type(parsed).__name__)
+ return None
# A batch request's input tokens scale roughly with its serialized size, so this
@@ -440,7 +503,7 @@ def _count_prompt_or_input_tokens(model: str, value: Any) -> int:
return 0
-def _get_batch_job_usage_from_response_body(response_body: dict, custom_llm_provider: str = "openai") -> Usage:
+def _get_batch_job_usage_from_response_body(response_body: Mapping, custom_llm_provider: str = "openai") -> Usage:
"""
Get the tokens of a batch job from the response body
"""
@@ -472,7 +535,7 @@ def _get_batch_job_usage_from_response_body(response_body: dict, custom_llm_prov
return usage
-def _get_anthropic_result_from_batch_results_line(batch_results_line: dict) -> dict:
+def _get_anthropic_result_from_batch_results_line(batch_results_line: Mapping) -> dict:
"""
Get the ``result`` object from a line of an Anthropic message batch results JSONL file.
@@ -482,7 +545,9 @@ def _get_anthropic_result_from_batch_results_line(batch_results_line: dict) -> d
return batch_results_line.get("result", None) or {}
-def _get_response_from_batch_job_output_file(batch_job_output_file: dict, custom_llm_provider: str = "openai") -> Any:
+def _get_response_from_batch_job_output_file(
+ batch_job_output_file: Mapping, custom_llm_provider: str = "openai"
+) -> Any:
"""
Get the response from the batch job output file
"""
@@ -495,7 +560,7 @@ def _get_response_from_batch_job_output_file(batch_job_output_file: dict, custom
return _response_body
-def _batch_response_was_successful(batch_job_output_file: dict, custom_llm_provider: str = "openai") -> bool:
+def _batch_response_was_successful(batch_job_output_file: Mapping, custom_llm_provider: str = "openai") -> bool:
"""
Check if the batch job response was successful
diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py
index 573882ebfca..254d663af93 100644
--- a/tests/test_litellm/batches/test_batch_utils.py
+++ b/tests/test_litellm/batches/test_batch_utils.py
@@ -150,13 +150,13 @@ def test_parse_jsonl_empty_content_is_empty_list():
assert bu._get_file_content_as_dictionary(b"") == []
-def test_parse_jsonl_malformed_raises():
- with pytest.raises(Exception):
- bu._get_file_content_as_dictionary(b"not valid json")
+def test_parse_jsonl_malformed_lines_skipped():
+ content = b'{"a": 1}\nnot valid json\n{"b": 2}\n'
+ assert bu._get_file_content_as_dictionary(content) == [{"a": 1}, {"b": 2}]
# =========================================================================== #
-# _iter_batch_input_lines / _iter_batch_input_entries (JSONL parsing)
+# _iter_batch_input_lines / _iter_batch_output_entries (JSONL parsing)
# =========================================================================== #
@@ -173,19 +173,17 @@ def test_iter_input_lines_empty():
assert list(bu._iter_batch_input_lines(b"")) == []
-def test_iter_input_entries_parses_each_row():
+def test_iter_output_entries_parses_each_row():
content = b'{"body": {"model": "gpt-4o"}}\n{"body": {"model": "claude-3"}}\n'
- assert list(bu._iter_batch_input_entries(content)) == [
+ assert list(bu._iter_batch_output_entries(content)) == [
{"body": {"model": "gpt-4o"}},
{"body": {"model": "claude-3"}},
]
-def test_iter_input_entries_raises_on_malformed_line():
- # _iter_batch_input_entries raises on a bad row; callers that must survive
- # bad rows iterate _iter_batch_input_lines and parse per-row instead.
- with pytest.raises(Exception):
- list(bu._iter_batch_input_entries(b'{"ok":1}\nnot-json\n'))
+def test_iter_output_entries_skips_malformed_and_non_object_lines():
+ content = b'{"ok": 1}\nnot-json\n[1, 2]\n{"ok": 2}\n'
+ assert list(bu._iter_batch_output_entries(content)) == [{"ok": 1}, {"ok": 2}]
# =========================================================================== #
@@ -471,6 +469,26 @@ def test_cost_from_content_completion_cost_path(monkeypatch):
assert len(calls) == 2 # failed row not costed
+def test_empty_body_line_does_not_zero_whole_batch():
+ # Regression: a status-200 row with an empty body made the real
+ # litellm.completion_cost raise ValueError, aborting the aggregation so the
+ # entire batch was booked at $0. The bad line must be skipped instead.
+ rows = [
+ _success_row(usage=_usage(10, 5)),
+ {
+ "custom_id": "request-poison-empty",
+ "response": {"status_code": 200, "request_id": "inject-empty-body", "body": {}},
+ },
+ _success_row(usage=_usage(20, 10)),
+ ]
+
+ cost, usage, models = bu._aggregate_batch_cost_usage_models(entries=rows, custom_llm_provider="openai")
+
+ assert cost > 0.0
+ assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (30, 15, 45)
+ assert models == ["gpt-4o", "gpt-4o"]
+
+
def test_cost_from_content_model_info_path(monkeypatch):
# model_info set -> batch_cost_calculator(prompt_cost, completion_cost).
import litellm.cost_calculator as cc
diff --git a/tests/test_litellm/proxy/hooks/test_batch_file_validation.py b/tests/test_litellm/proxy/hooks/test_batch_file_validation.py
index 1ce1a2f3e51..28624254565 100644
--- a/tests/test_litellm/proxy/hooks/test_batch_file_validation.py
+++ b/tests/test_litellm/proxy/hooks/test_batch_file_validation.py
@@ -1739,18 +1739,18 @@ def _make_batch_input_bytes(n_rows: int, padding: int = 200) -> bytes:
return ("\n".join(rows)).encode("utf-8")
-def test_iter_batch_input_entries_matches_dict_list():
+def test_iter_batch_output_entries_matches_dict_list():
from litellm.batches.batch_utils import (
_get_file_content_as_dictionary,
- _iter_batch_input_entries,
+ _iter_batch_output_entries,
)
raw = _make_batch_input_bytes(50)
- streamed = list(_iter_batch_input_entries(raw))
+ streamed = list(_iter_batch_output_entries(raw))
assert streamed == _get_file_content_as_dictionary(raw)
assert streamed[0]["custom_id"] == "request-0"
# tolerant of blank lines and a missing trailing newline
- assert list(_iter_batch_input_entries(raw + b"\n\n")) == streamed
+ assert list(_iter_batch_output_entries(raw + b"\n\n")) == streamed
def test_streaming_count_peak_below_dict_list():
@@ -1759,7 +1759,7 @@ def test_streaming_count_peak_below_dict_list():
from litellm.batches.batch_utils import (
_get_file_content_as_dictionary,
- _iter_batch_input_entries,
+ _iter_batch_output_entries,
)
raw = _make_batch_input_bytes(8000)
@@ -1777,7 +1777,7 @@ def test_streaming_count_peak_below_dict_list():
def _stream():
count = 0
models: set = set()
- for entry in _iter_batch_input_entries(raw):
+ for entry in _iter_batch_output_entries(raw):
count += 1
model = (entry.get("body") or {}).get("model")
if model:
diff --git a/type-discipline-budget.json b/type-discipline-budget.json
index f8e481dc142..43753224714 100644
--- a/type-discipline-budget.json
+++ b/type-discipline-budget.json
@@ -1,6 +1,6 @@
{
"LIT001": {
- "limit": 22894
+ "limit": 22891
},
"LIT002": {
"limit": 26888
From f614f039c56c753c3ca0ad8c884eaa81e5976da3 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Tue, 18 Aug 2026 23:00:47 -0700
Subject: [PATCH 22/88] fix(ui): drop stale user search answers so Enter
commits the current match
The Add Member modal and the Create Key owner picker both search users
server-side on a 300ms debounce with nothing sequencing the requests, so a
slow answer to an earlier, shorter search can land after the current one and
replace the list. With the first row highlighted at all times, Enter then
commits whoever sits on top of that abandoned batch, ordered newest account
first rather than best match.
Each search now takes a sequence number and only the newest one is allowed to
reach the option list or clear the spinner.
---
.../user_search_modal.test.tsx | 55 +++++++++++++++++++
.../common_components/user_search_modal.tsx | 10 +++-
.../create_key_button.integration.test.tsx | 34 +++++++++++-
.../organisms/create_key_button.tsx | 10 +++-
4 files changed, 104 insertions(+), 5 deletions(-)
diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx
index cbaf00b7131..98b85c738b4 100644
--- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx
+++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx
@@ -198,3 +198,58 @@ describe("UserSearchModal submit payload", () => {
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
});
});
+
+describe("UserSearchModal out-of-order search results", () => {
+ const answers = new Map void>();
+
+ beforeEach(() => {
+ answers.clear();
+ vi.mocked(userFilterUICall).mockReset();
+ vi.mocked(userFilterUICall).mockImplementation(
+ (_accessToken, params) =>
+ new Promise((resolve) => {
+ answers.set(params.get("user_email") ?? "", resolve);
+ }) as never,
+ );
+ });
+
+ const answerFor = async (search: string, users: { user_id: string; user_email: string }[]) => {
+ const resolve = answers.get(search);
+ if (resolve === undefined) throw new Error(`no pending search for "${search}"`);
+ await act(async () => {
+ resolve(users);
+ });
+ };
+
+ it("commits the current search's match when an abandoned search answers last", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+ render();
+
+ const input = getEmailSearchInput();
+ await user.click(input);
+ await user.type(input, "ali");
+ await waitFor(() => expect(answers.has("ali")).toBe(true), { timeout: 3000 });
+
+ await user.type(input, "ce.smith@example.com");
+ await waitFor(() => expect(answers.has("alice.smith@example.com")).toBe(true), { timeout: 3000 });
+
+ await answerFor("alice.smith@example.com", [{ user_id: "u-smith", user_email: "alice.smith@example.com" }]);
+ await screen.findByRole("option", { name: "alice.smith@example.com" });
+
+ await answerFor("ali", [
+ { user_id: "u-jones", user_email: "alice.jones@example.com" },
+ { user_id: "u-smith", user_email: "alice.smith@example.com" },
+ ]);
+
+ await user.keyboard("{Enter}");
+ await user.click(screen.getByRole("button", { name: /add member/i }));
+
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+ expect(onSubmit.mock.calls[0][0]).toStrictEqual({
+ user_email: "alice.smith@example.com",
+ user_id: "u-smith",
+ role: "user",
+ });
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx
index aeead9d82e6..71d38c6db25 100644
--- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx
+++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useRef, useState } from "react";
import { Modal, Alert } from "antd";
import { UserAddOutlined } from "@ant-design/icons";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
@@ -78,8 +78,13 @@ const UserSearchModal: React.FC = ({
const [loading, setLoading] = useState(false);
const [selectedField, setSelectedField] = useState<"user_email" | "user_id">("user_email");
const [isSubmitting, setIsSubmitting] = useState(false);
+ const latestSearchRef = useRef(0);
const fetchUsers = async (searchText: string, fieldName: "user_email" | "user_id"): Promise => {
+ const searchId = latestSearchRef.current + 1;
+ latestSearchRef.current = searchId;
+ const isLatestSearch = (): boolean => searchId === latestSearchRef.current;
+
if (!searchText) {
setUserOptions([]);
return;
@@ -96,6 +101,7 @@ const UserSearchModal: React.FC = ({
return;
}
const response = await userFilterUICall(accessToken, params);
+ if (!isLatestSearch()) return;
const data: User[] = response;
const options: UserOption[] = data.map((user) => ({
@@ -107,7 +113,7 @@ const UserSearchModal: React.FC = ({
} catch (error) {
console.error("Error fetching users:", error);
} finally {
- setLoading(false);
+ if (isLatestSearch()) setLoading(false);
}
};
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx
index 9788d365fd0..c413bbeebfc 100644
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx
@@ -1,6 +1,6 @@
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
+import { act, renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
import type { Team } from "../key_team_helpers/key_list";
import { keyCreateCall, keyCreateServiceAccountCall, modelAvailableCall, userFilterUICall } from "../networking";
import CreateKey from "./create_key_button";
@@ -803,6 +803,38 @@ describe("CreateKey", () => {
vi.useRealTimers();
}
});
+
+ it("keeps the current search's users when an abandoned search answers last", async () => {
+ const answers = new Map void>();
+ vi.mocked(userFilterUICall).mockImplementation(
+ (_accessToken, params) =>
+ new Promise((resolve) => {
+ answers.set(params.get("user_email") ?? "", resolve);
+ }) as never,
+ );
+
+ const user = userEvent.setup();
+ renderCreateKey({ autoOpenCreate: true, prefillData: { owned_by: "another_user" } });
+ const search = antdSearchInput(await screen.findByText("Type email to search for users"));
+
+ await user.type(search, "ali");
+ await waitFor(() => expect(answers.has("ali")).toBe(true), { timeout: 3000 });
+
+ await user.type(search, "ce.smith@example.com");
+ await waitFor(() => expect(answers.has("alice.smith@example.com")).toBe(true), { timeout: 3000 });
+
+ await act(async () => {
+ answers.get("alice.smith@example.com")?.([{ user_id: "u-smith", user_email: "alice.smith@example.com" }]);
+ });
+ await screen.findByTitle("alice.smith@example.com (u-smith)");
+
+ await act(async () => {
+ answers.get("ali")?.([{ user_id: "u-jones", user_email: "alice.jones@example.com" }]);
+ });
+
+ expect(screen.queryByTitle("alice.jones@example.com (u-jones)")).not.toBeInTheDocument();
+ expect(screen.getByTitle("alice.smith@example.com (u-smith)")).toBeInTheDocument();
+ });
});
describe("created key display", () => {
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
index 77ef31e0ede..1e7fff687f1 100644
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
@@ -27,7 +27,7 @@ import {
import { ChevronDown } from "lucide-react";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useRef, useState } from "react";
import { rolesWithWriteAccess } from "../../utils/roles";
import AgentSelector from "../agent_management/AgentSelector";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
@@ -195,6 +195,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
const [possibleUIRoles, setPossibleUIRoles] = useState>>({});
const [userOptions, setUserOptions] = useState([]);
const [userSearchLoading, setUserSearchLoading] = useState(false);
+ const latestUserSearchRef = useRef(0);
const [disabledCallbacks, setDisabledCallbacks] = useState([]);
const [keyType, setKeyType] = useState("llm_api");
const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({});
@@ -503,6 +504,10 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
};
const fetchUsers = async (searchText: string): Promise => {
+ const searchId = latestUserSearchRef.current + 1;
+ latestUserSearchRef.current = searchId;
+ const isLatestSearch = (): boolean => searchId === latestUserSearchRef.current;
+
if (!searchText) {
setUserOptions([]);
return;
@@ -516,6 +521,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
return;
}
const response = await userFilterUICall(accessToken, params);
+ if (!isLatestSearch()) return;
const data: User[] = response;
const options: UserOption[] = data.map((user) => ({
@@ -529,7 +535,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
console.error("Error fetching users:", error);
toast.fromError("Failed to search for users");
} finally {
- setUserSearchLoading(false);
+ if (isLatestSearch()) setUserSearchLoading(false);
}
};
From 5c6391d7d2590f19e6cd5bc2f5d89c5aa4ffe5f1 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Wed, 19 Aug 2026 02:15:22 -0700
Subject: [PATCH 23/88] refactor(batches): parameterize the batch output
Mapping annotations
---
litellm/batches/batch_utils.py | 18 +++++++++++-------
tests/test_litellm/batches/test_batch_utils.py | 5 ++---
2 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py
index feb84ccd8a6..0f8acce3379 100644
--- a/litellm/batches/batch_utils.py
+++ b/litellm/batches/batch_utils.py
@@ -118,7 +118,7 @@ def _iter_successful_output_line_stats(
def _safe_output_line_stats(
- entry: Mapping,
+ entry: Mapping[str, Any],
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"],
model_name: str | None,
model_info: ModelInfo | None,
@@ -142,7 +142,7 @@ def _safe_output_line_stats(
def _compute_output_line_stats(
- entry: Mapping,
+ entry: Mapping[str, Any],
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"],
model_name: str | None,
model_info: ModelInfo | None,
@@ -171,7 +171,7 @@ def _compute_output_line_stats(
def _output_line_cost(
- response_body: Mapping,
+ response_body: Mapping[str, Any],
usage: Usage,
custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"],
model_name: str | None,
@@ -503,7 +503,9 @@ def _count_prompt_or_input_tokens(model: str, value: Any) -> int:
return 0
-def _get_batch_job_usage_from_response_body(response_body: Mapping, custom_llm_provider: str = "openai") -> Usage:
+def _get_batch_job_usage_from_response_body(
+ response_body: Mapping[str, Any], custom_llm_provider: str = "openai"
+) -> Usage:
"""
Get the tokens of a batch job from the response body
"""
@@ -535,7 +537,7 @@ def _get_batch_job_usage_from_response_body(response_body: Mapping, custom_llm_p
return usage
-def _get_anthropic_result_from_batch_results_line(batch_results_line: Mapping) -> dict:
+def _get_anthropic_result_from_batch_results_line(batch_results_line: Mapping[str, Any]) -> dict:
"""
Get the ``result`` object from a line of an Anthropic message batch results JSONL file.
@@ -546,7 +548,7 @@ def _get_anthropic_result_from_batch_results_line(batch_results_line: Mapping) -
def _get_response_from_batch_job_output_file(
- batch_job_output_file: Mapping, custom_llm_provider: str = "openai"
+ batch_job_output_file: Mapping[str, Any], custom_llm_provider: str = "openai"
) -> Any:
"""
Get the response from the batch job output file
@@ -560,7 +562,9 @@ def _get_response_from_batch_job_output_file(
return _response_body
-def _batch_response_was_successful(batch_job_output_file: Mapping, custom_llm_provider: str = "openai") -> bool:
+def _batch_response_was_successful(
+ batch_job_output_file: Mapping[str, Any], custom_llm_provider: str = "openai"
+) -> bool:
"""
Check if the batch job response was successful
diff --git a/tests/test_litellm/batches/test_batch_utils.py b/tests/test_litellm/batches/test_batch_utils.py
index 254d663af93..a30420e6224 100644
--- a/tests/test_litellm/batches/test_batch_utils.py
+++ b/tests/test_litellm/batches/test_batch_utils.py
@@ -470,9 +470,8 @@ def test_cost_from_content_completion_cost_path(monkeypatch):
def test_empty_body_line_does_not_zero_whole_batch():
- # Regression: a status-200 row with an empty body made the real
- # litellm.completion_cost raise ValueError, aborting the aggregation so the
- # entire batch was booked at $0. The bad line must be skipped instead.
+ """A status-200 row with an empty body makes litellm.completion_cost raise;
+ that line must be skipped instead of zeroing the whole batch."""
rows = [
_success_row(usage=_usage(10, 5)),
{
From 5a0a8ffafe6b8af4c73cc28b5cbb17376a835350 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:17:15 +0000
Subject: [PATCH 24/88] fix(model_prices): correct gemini and deepseek pricing
and add deprecation dates
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
...odel_prices_and_context_window_backup.json | 54 ++++++++++---------
model_prices_and_context_window.json | 54 ++++++++++---------
2 files changed, 60 insertions(+), 48 deletions(-)
diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index 07f9027313b..1a1652451a1 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -10004,6 +10004,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,
@@ -15042,6 +15043,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,
@@ -20562,8 +20564,8 @@
"web_search_billing_unit": "per_query"
},
"gemini/gemini-3.1-flash-image": {
- "input_cost_per_token": 2.5e-07,
- "input_cost_per_token_batches": 1.25e-07,
+ "input_cost_per_token": 5e-07,
+ "input_cost_per_token_batches": 2.5e-07,
"litellm_provider": "gemini",
"max_input_tokens": 65536,
"max_output_tokens": 32768,
@@ -20571,8 +20573,8 @@
"mode": "image_generation",
"output_cost_per_image": 0.045,
"output_cost_per_image_token": 6e-05,
- "output_cost_per_token": 1.5e-06,
- "output_cost_per_token_batches": 7.5e-07,
+ "output_cost_per_token": 3e-06,
+ "output_cost_per_token_batches": 1.5e-06,
"rpm": 1000,
"tpm": 4000000,
"source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image",
@@ -20605,8 +20607,8 @@
},
"gemini/gemini-3.1-flash-image-preview": {
"deprecation_date": "2026-06-25",
- "input_cost_per_token": 2.5e-07,
- "input_cost_per_token_batches": 1.25e-07,
+ "input_cost_per_token": 5e-07,
+ "input_cost_per_token_batches": 2.5e-07,
"litellm_provider": "gemini",
"max_input_tokens": 65536,
"max_output_tokens": 32768,
@@ -20614,8 +20616,8 @@
"mode": "image_generation",
"output_cost_per_image": 0.045,
"output_cost_per_image_token": 6e-05,
- "output_cost_per_token": 1.5e-06,
- "output_cost_per_token_batches": 7.5e-07,
+ "output_cost_per_token": 3e-06,
+ "output_cost_per_token_batches": 1.5e-06,
"rpm": 1000,
"tpm": 4000000,
"source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image-preview",
@@ -23245,6 +23247,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,
@@ -36161,6 +36164,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,
@@ -36170,6 +36174,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,
@@ -36179,6 +36184,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,
@@ -47975,15 +47981,15 @@
},
"deepseek-v4-flash": {
"cache_creation_input_token_cost": 0.0,
- "cache_read_input_token_cost": 2.8e-09,
- "input_cost_per_token": 1.4e-07,
- "input_cost_per_token_cache_hit": 2.8e-09,
+ "cache_read_input_token_cost": 1.4e-08,
+ "input_cost_per_token": 4.4e-07,
+ "input_cost_per_token_cache_hit": 1.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
- "output_cost_per_token": 2.8e-07,
+ "output_cost_per_token": 1.32e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
@@ -48001,15 +48007,15 @@
},
"deepseek-v4-pro": {
"cache_creation_input_token_cost": 0.0,
- "cache_read_input_token_cost": 3.625e-09,
- "input_cost_per_token": 4.35e-07,
- "input_cost_per_token_cache_hit": 3.625e-09,
+ "cache_read_input_token_cost": 4.4e-08,
+ "input_cost_per_token": 1.32e-06,
+ "input_cost_per_token_cache_hit": 4.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
- "output_cost_per_token": 8.7e-07,
+ "output_cost_per_token": 3.96e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
@@ -48027,15 +48033,15 @@
},
"deepseek/deepseek-v4-flash": {
"cache_creation_input_token_cost": 0.0,
- "cache_read_input_token_cost": 2.8e-09,
- "input_cost_per_token": 1.4e-07,
- "input_cost_per_token_cache_hit": 2.8e-09,
+ "cache_read_input_token_cost": 1.4e-08,
+ "input_cost_per_token": 4.4e-07,
+ "input_cost_per_token_cache_hit": 1.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
- "output_cost_per_token": 2.8e-07,
+ "output_cost_per_token": 1.32e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
@@ -48053,15 +48059,15 @@
},
"deepseek/deepseek-v4-pro": {
"cache_creation_input_token_cost": 0.0,
- "cache_read_input_token_cost": 3.625e-09,
- "input_cost_per_token": 4.35e-07,
- "input_cost_per_token_cache_hit": 3.625e-09,
+ "cache_read_input_token_cost": 4.4e-08,
+ "input_cost_per_token": 1.32e-06,
+ "input_cost_per_token_cache_hit": 4.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
- "output_cost_per_token": 8.7e-07,
+ "output_cost_per_token": 3.96e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 07f9027313b..1a1652451a1 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -10004,6 +10004,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,
@@ -15042,6 +15043,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,
@@ -20562,8 +20564,8 @@
"web_search_billing_unit": "per_query"
},
"gemini/gemini-3.1-flash-image": {
- "input_cost_per_token": 2.5e-07,
- "input_cost_per_token_batches": 1.25e-07,
+ "input_cost_per_token": 5e-07,
+ "input_cost_per_token_batches": 2.5e-07,
"litellm_provider": "gemini",
"max_input_tokens": 65536,
"max_output_tokens": 32768,
@@ -20571,8 +20573,8 @@
"mode": "image_generation",
"output_cost_per_image": 0.045,
"output_cost_per_image_token": 6e-05,
- "output_cost_per_token": 1.5e-06,
- "output_cost_per_token_batches": 7.5e-07,
+ "output_cost_per_token": 3e-06,
+ "output_cost_per_token_batches": 1.5e-06,
"rpm": 1000,
"tpm": 4000000,
"source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image",
@@ -20605,8 +20607,8 @@
},
"gemini/gemini-3.1-flash-image-preview": {
"deprecation_date": "2026-06-25",
- "input_cost_per_token": 2.5e-07,
- "input_cost_per_token_batches": 1.25e-07,
+ "input_cost_per_token": 5e-07,
+ "input_cost_per_token_batches": 2.5e-07,
"litellm_provider": "gemini",
"max_input_tokens": 65536,
"max_output_tokens": 32768,
@@ -20614,8 +20616,8 @@
"mode": "image_generation",
"output_cost_per_image": 0.045,
"output_cost_per_image_token": 6e-05,
- "output_cost_per_token": 1.5e-06,
- "output_cost_per_token_batches": 7.5e-07,
+ "output_cost_per_token": 3e-06,
+ "output_cost_per_token_batches": 1.5e-06,
"rpm": 1000,
"tpm": 4000000,
"source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3.1-flash-image-preview",
@@ -23245,6 +23247,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,
@@ -36161,6 +36164,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,
@@ -36170,6 +36174,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,
@@ -36179,6 +36184,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,
@@ -47975,15 +47981,15 @@
},
"deepseek-v4-flash": {
"cache_creation_input_token_cost": 0.0,
- "cache_read_input_token_cost": 2.8e-09,
- "input_cost_per_token": 1.4e-07,
- "input_cost_per_token_cache_hit": 2.8e-09,
+ "cache_read_input_token_cost": 1.4e-08,
+ "input_cost_per_token": 4.4e-07,
+ "input_cost_per_token_cache_hit": 1.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
- "output_cost_per_token": 2.8e-07,
+ "output_cost_per_token": 1.32e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
@@ -48001,15 +48007,15 @@
},
"deepseek-v4-pro": {
"cache_creation_input_token_cost": 0.0,
- "cache_read_input_token_cost": 3.625e-09,
- "input_cost_per_token": 4.35e-07,
- "input_cost_per_token_cache_hit": 3.625e-09,
+ "cache_read_input_token_cost": 4.4e-08,
+ "input_cost_per_token": 1.32e-06,
+ "input_cost_per_token_cache_hit": 4.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
- "output_cost_per_token": 8.7e-07,
+ "output_cost_per_token": 3.96e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
@@ -48027,15 +48033,15 @@
},
"deepseek/deepseek-v4-flash": {
"cache_creation_input_token_cost": 0.0,
- "cache_read_input_token_cost": 2.8e-09,
- "input_cost_per_token": 1.4e-07,
- "input_cost_per_token_cache_hit": 2.8e-09,
+ "cache_read_input_token_cost": 1.4e-08,
+ "input_cost_per_token": 4.4e-07,
+ "input_cost_per_token_cache_hit": 1.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
- "output_cost_per_token": 2.8e-07,
+ "output_cost_per_token": 1.32e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
@@ -48053,15 +48059,15 @@
},
"deepseek/deepseek-v4-pro": {
"cache_creation_input_token_cost": 0.0,
- "cache_read_input_token_cost": 3.625e-09,
- "input_cost_per_token": 4.35e-07,
- "input_cost_per_token_cache_hit": 3.625e-09,
+ "cache_read_input_token_cost": 4.4e-08,
+ "input_cost_per_token": 1.32e-06,
+ "input_cost_per_token_cache_hit": 4.4e-08,
"litellm_provider": "deepseek",
"max_input_tokens": 1000000,
"max_output_tokens": 393216,
"max_tokens": 393216,
"mode": "chat",
- "output_cost_per_token": 8.7e-07,
+ "output_cost_per_token": 3.96e-06,
"source": "https://api-docs.deepseek.com/quick_start/pricing",
"supported_endpoints": [
"/v1/chat/completions"
From dcd8bb3f38561c0030499a0fa074cddee1aed4e8 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 19 Aug 2026 13:47:44 +0000
Subject: [PATCH 25/88] test(model_prices): update DeepSeek V4 pricing
expectations
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
tests/test_litellm/test_utils.py | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py
index efccdc4a986..0218f8f6219 100644
--- a/tests/test_litellm/test_utils.py
+++ b/tests/test_litellm/test_utils.py
@@ -3787,8 +3787,8 @@ def test_deepseek_v4_models_in_cost_map():
configured in model_prices_and_context_window.json.
Prices sourced from https://api-docs.deepseek.com/quick_start/pricing:
- - deepseek-v4-flash: $0.14/M input, $0.28/M output
- - deepseek-v4-pro: $0.435/M input, $0.87/M output (75% discounted active price)
+ - deepseek-v4-flash: $0.44/M input, $1.32/M output
+ - deepseek-v4-pro: $1.32/M input, $3.96/M output
Closes https://github.com/BerriAI/litellm/issues/26709
"""
@@ -3801,8 +3801,8 @@ def test_deepseek_v4_models_in_cost_map():
# --- bare model names ---
for key, expected_input, expected_output, expected_cache in [
- ("deepseek-v4-flash", 1.4e-07, 2.8e-07, 2.8e-09),
- ("deepseek-v4-pro", 4.35e-07, 8.7e-07, 3.625e-09),
+ ("deepseek-v4-flash", 4.4e-07, 1.32e-06, 1.4e-08),
+ ("deepseek-v4-pro", 1.32e-06, 3.96e-06, 4.4e-08),
]:
info = model_cost.get(key)
assert info is not None, f"{key} missing from model_prices_and_context_window.json"
@@ -3817,8 +3817,8 @@ def test_deepseek_v4_models_in_cost_map():
# --- provider-prefixed names ---
for key, expected_input, expected_output, expected_cache in [
- ("deepseek/deepseek-v4-flash", 1.4e-07, 2.8e-07, 2.8e-09),
- ("deepseek/deepseek-v4-pro", 4.35e-07, 8.7e-07, 3.625e-09),
+ ("deepseek/deepseek-v4-flash", 4.4e-07, 1.32e-06, 1.4e-08),
+ ("deepseek/deepseek-v4-pro", 1.32e-06, 3.96e-06, 4.4e-08),
]:
info = model_cost.get(key)
assert info is not None, f"{key} missing from model_prices_and_context_window.json"
@@ -3845,8 +3845,8 @@ def test_deepseek_v4_models_in_backup_cost_map():
# --- bare model names ---
for key, expected_input, expected_output, expected_cache in [
- ("deepseek-v4-flash", 1.4e-07, 2.8e-07, 2.8e-09),
- ("deepseek-v4-pro", 4.35e-07, 8.7e-07, 3.625e-09),
+ ("deepseek-v4-flash", 4.4e-07, 1.32e-06, 1.4e-08),
+ ("deepseek-v4-pro", 1.32e-06, 3.96e-06, 4.4e-08),
]:
info = model_cost.get(key)
assert info is not None, f"{key} missing from backup JSON"
@@ -3859,8 +3859,8 @@ def test_deepseek_v4_models_in_backup_cost_map():
# --- provider-prefixed names ---
for key, expected_input, expected_output, expected_cache in [
- ("deepseek/deepseek-v4-flash", 1.4e-07, 2.8e-07, 2.8e-09),
- ("deepseek/deepseek-v4-pro", 4.35e-07, 8.7e-07, 3.625e-09),
+ ("deepseek/deepseek-v4-flash", 4.4e-07, 1.32e-06, 1.4e-08),
+ ("deepseek/deepseek-v4-pro", 1.32e-06, 3.96e-06, 4.4e-08),
]:
info = model_cost.get(key)
assert info is not None, f"{key} missing from backup JSON"
From ae18f055ee3ce5983b3023d533da7b7cc9676d22 Mon Sep 17 00:00:00 2001
From: yassin
Date: Wed, 19 Aug 2026 19:07:17 +0000
Subject: [PATCH 26/88] fix(search): harden AgentCore gateway trust, error and
SSE handling
Refuse to SigV4-sign requests to hosts that are neither an AgentCore gateway
hostname nor AGENTCORE_GATEWAY_URL's host, match gateway hostnames on the URL
host instead of anywhere in the URL, accept the env token when api_base is a
real gateway, raise on tools/call responses with result.isError, and split
CRLF-framed SSE events.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/bedrock/search/transformation.py | 52 ++++++-
.../test_agentcore_search_transformation.py | 141 +++++++++++++++---
2 files changed, 161 insertions(+), 32 deletions(-)
diff --git a/litellm/llms/bedrock/search/transformation.py b/litellm/llms/bedrock/search/transformation.py
index 5567dc8403e..85c5818031b 100644
--- a/litellm/llms/bedrock/search/transformation.py
+++ b/litellm/llms/bedrock/search/transformation.py
@@ -71,13 +71,19 @@ AGENTCORE_TOOL_NAME_SUFFIX: Final = "___WebSearch"
# servers that predate the header ignore it.
AGENTCORE_MCP_PROTOCOL_VERSION: Final = "2025-06-18"
-_GATEWAY_REGION_PATTERN: Final = re.compile(r"\.gateway\.bedrock-agentcore\.([a-z0-9-]+)\.amazonaws\.com")
+# Matched against the URL host so a crafted path or query string can't pass for
+# a gateway hostname.
+_GATEWAY_HOST_PATTERN: Final = re.compile(r"[a-z0-9-]+\.gateway\.bedrock-agentcore\.([a-z0-9-]+)\.amazonaws\.com")
-_SSE_EVENT_SEPARATOR: Final = re.compile(r"\n[ \t]*\n")
+_SSE_EVENT_SEPARATOR: Final = re.compile(r"\r?\n[ \t]*\r?\n")
_SSE_LINE_PREFIXES: Final = ("event:", "data:", ":", "id:", "retry:")
+def _gateway_host_match(api_base: str) -> re.Match[str] | None:
+ return _GATEWAY_HOST_PATTERN.fullmatch(httpx.URL(api_base).host)
+
+
def _string_field(item: Mapping[str, object], *keys: str) -> str | None:
return next(
(value for key in keys if isinstance(value := item.get(key), str) and value),
@@ -245,16 +251,17 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
if not isinstance(request_data, dict):
raise TypeError("AgentCore search expects a single dict request body")
- # Server-managed token fallback is gated on the request targeting the
- # operator-configured gateway host, otherwise an authenticated caller
- # could point api_base at their own server (e.g. via
- # /search_tools/test_connection) and receive AGENTCORE_GATEWAY_TOKEN.
+ # Server-managed credentials only go to a trusted host, otherwise an
+ # authenticated caller could point api_base at their own server (e.g. via
+ # /search_tools/test_connection) and collect AGENTCORE_GATEWAY_TOKEN or a
+ # SigV4 signature with the proxy's credential scope and session token.
+ gateway_host_match: Final = _gateway_host_match(api_base)
bearer_token: Final = self.resolve_server_api_key(
caller_api_key=api_key,
caller_api_base=api_base,
key_env_vars=("AGENTCORE_GATEWAY_TOKEN",),
base_env_var="AGENTCORE_GATEWAY_URL",
- default_api_base=None,
+ default_api_base=api_base if gateway_host_match else None,
)
if bearer_token:
bearer_headers: Final = { # mutable-ok: httpx request headers are a dict
@@ -263,6 +270,13 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
}
return bearer_headers, json.dumps(request_data).encode()
+ if gateway_host_match is None and not self._is_configured_gateway(api_base):
+ raise ValueError(
+ f"Refusing to send SigV4-signed AgentCore requests to '{api_base}': it is neither an "
+ "AgentCore gateway hostname nor the host in AGENTCORE_GATEWAY_URL. Set "
+ "AGENTCORE_GATEWAY_URL to authorize a custom gateway hostname."
+ )
+
signing_params: Final = (
optional_params
if optional_params.get("aws_region_name") is not None
@@ -284,6 +298,13 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
api_key="",
)
+ @staticmethod
+ def _is_configured_gateway(api_base: str) -> bool:
+ configured: Final = get_secret_str("AGENTCORE_GATEWAY_URL")
+ if not configured:
+ return False
+ return httpx.URL(configured).host == httpx.URL(api_base).host
+
@staticmethod
def _signing_region(api_base: str) -> str:
"""
@@ -296,7 +317,7 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
nothing rather than silently signing for a guessed region the gateway
would reject with a confusing auth error.
"""
- match: Final = _GATEWAY_REGION_PATTERN.search(api_base)
+ match: Final = _gateway_host_match(api_base)
if match:
return match.group(1)
@@ -336,6 +357,15 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
message=f"AgentCore gateway MCP error: {error}",
)
+ # A failed tools/call is reported in-band, as HTTP 200 with result.isError
+ # and the failure text where the results would be.
+ result: Final = response_json.get("result")
+ if isinstance(result, dict) and result.get("isError"):
+ raise BedrockError(
+ status_code=raw_response.status_code if raw_response.status_code >= 400 else 502,
+ message=f"AgentCore web search tool error: {self._tool_error_message(response_json)}",
+ )
+
return SearchResponse(
results=[ # mutable-ok: SearchResponse.results is a pydantic list field
_to_search_result(item)
@@ -345,6 +375,12 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
object="search",
)
+ def _tool_error_message(self, response_json: Mapping[str, object]) -> str:
+ texts: Final = tuple(
+ text for block in self._text_blocks(response_json) if isinstance(text := block.get("text"), str)
+ )
+ return " ".join(texts) if texts else json.dumps(response_json.get("result"))[:500]
+
@staticmethod
def _text_blocks(response_json: Mapping[str, object]) -> tuple[Mapping[str, object], ...]:
result: Final = response_json.get("result")
diff --git a/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py b/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py
index 63ec2286c3d..889f78c58b9 100644
--- a/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py
+++ b/tests/test_litellm/llms/bedrock/search/test_agentcore_search_transformation.py
@@ -231,6 +231,37 @@ class TestAgentCoreSearch:
with pytest.raises(Exception, match="tool not found"):
config.transform_search_response(raw_response=mock_response, logging_obj=MagicMock())
+ def test_transform_search_response_raises_on_tool_error(self):
+ """A failed tools/call comes back as HTTP 200 with result.isError; it must not be
+ reported to the caller as a successful search with zero results."""
+ config = AgentCoreSearchConfig()
+ mock_response = _make_mock_response(
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "isError": True,
+ "content": [{"type": "text", "text": "AccessDeniedException: not authorized"}],
+ },
+ }
+ )
+ with pytest.raises(Exception, match="AccessDeniedException"):
+ config.transform_search_response(raw_response=mock_response, logging_obj=MagicMock())
+
+ def test_transform_search_response_parses_crlf_framed_sse(self):
+ """SSE streams may be CRLF framed; events must still split into separate events."""
+ config = AgentCoreSearchConfig()
+ progress = {"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progress": 1}}
+ sse_text = (
+ f"event: message\r\ndata: {json.dumps(progress)}\r\n\r\n"
+ f"event: message\r\ndata: {json.dumps(_mcp_response_body())}\r\n\r\n"
+ )
+ mock_response = _make_mock_response(text=sse_text)
+
+ response = config.transform_search_response(raw_response=mock_response, logging_obj=MagicMock())
+ assert len(response.results) == 2
+ assert response.results[0].title == "Test Result 1"
+
def test_sign_request_uses_bearer_token_when_api_key_set(self):
"""CUSTOM_JWT gateways: api_key is sent as a bearer token, no SigV4."""
config = AgentCoreSearchConfig()
@@ -280,6 +311,54 @@ class TestAgentCoreSearch:
os.environ.pop("AGENTCORE_GATEWAY_TOKEN", None)
os.environ.pop("AGENTCORE_GATEWAY_URL", None)
+ def test_sign_request_uses_env_token_for_gateway_api_base_without_gateway_url(self):
+ """api_base pointing at a real gateway is a trusted destination for the env token,
+ so operators configuring api_base in yaml don't also need AGENTCORE_GATEWAY_URL."""
+ config = AgentCoreSearchConfig()
+ os.environ["AGENTCORE_GATEWAY_TOKEN"] = "env-jwt-token"
+ os.environ.pop("AGENTCORE_GATEWAY_URL", None)
+ try:
+ headers, _ = config.sign_request(
+ headers={},
+ optional_params={},
+ request_data={"jsonrpc": "2.0"},
+ api_base=GATEWAY_URL,
+ )
+ assert headers["Authorization"] == "Bearer env-jwt-token"
+ finally:
+ os.environ.pop("AGENTCORE_GATEWAY_TOKEN", None)
+
+ @pytest.mark.parametrize(
+ "untrusted_api_base",
+ [
+ "https://attacker.example.com/mcp",
+ # gateway hostname in the path/query must not pass for the host
+ "https://attacker.example.com/gw.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp",
+ ],
+ )
+ def test_sign_request_refuses_sigv4_to_untrusted_host(self, untrusted_api_base):
+ """A SigV4 signature carries the proxy's credential scope and session token, so it
+ must never be sent to a host that is not the operator's gateway."""
+ config = AgentCoreSearchConfig()
+ os.environ.pop("AGENTCORE_GATEWAY_TOKEN", None)
+ os.environ["AGENTCORE_GATEWAY_URL"] = GATEWAY_URL
+ try:
+ with patch.object(
+ AgentCoreSearchConfig.__mro__[2], # BaseAWSLLM
+ "_sign_request",
+ return_value=({}, b"{}"),
+ ) as mock_base_sign:
+ with pytest.raises(ValueError, match="Refusing to send"):
+ config.sign_request(
+ headers={},
+ optional_params={"aws_region_name": "us-east-1"},
+ request_data={"jsonrpc": "2.0"},
+ api_base=untrusted_api_base,
+ )
+ mock_base_sign.assert_not_called()
+ finally:
+ os.environ.pop("AGENTCORE_GATEWAY_URL", None)
+
def test_sign_request_does_not_leak_bedrock_bearer_token(self):
"""AWS_BEARER_TOKEN_BEDROCK is a Bedrock Runtime credential — it must not
replace SigV4 on requests to an AgentCore gateway."""
@@ -303,39 +382,49 @@ class TestAgentCoreSearch:
def test_sign_request_custom_hostname_requires_region(self):
"""Custom hostname + empty AWS config chain → clear error, no guessed region."""
config = AgentCoreSearchConfig()
+ custom_url = "https://gateway.internal.example.com/mcp"
+ os.environ["AGENTCORE_GATEWAY_URL"] = custom_url
mock_session = MagicMock()
mock_session.region_name = None # nothing configured anywhere
- with patch("boto3.Session", return_value=mock_session):
- with pytest.raises(ValueError, match="signing region"):
- config.sign_request(
- headers={},
- optional_params={},
- request_data={"jsonrpc": "2.0"},
- api_base="https://gateway.internal.example.com/mcp",
- )
+ try:
+ with patch("boto3.Session", return_value=mock_session):
+ with pytest.raises(ValueError, match="signing region"):
+ config.sign_request(
+ headers={},
+ optional_params={},
+ request_data={"jsonrpc": "2.0"},
+ api_base=custom_url,
+ )
+ finally:
+ os.environ.pop("AGENTCORE_GATEWAY_URL", None)
def test_sign_request_custom_hostname_uses_shared_config_region(self):
"""Custom hostname + region from AWS shared config (profile) must be honored."""
config = AgentCoreSearchConfig()
+ custom_url = "https://gateway.internal.example.com/mcp"
+ os.environ["AGENTCORE_GATEWAY_URL"] = custom_url
mock_session = MagicMock()
mock_session.region_name = "eu-west-1" # e.g. from ~/.aws/config profile
- with (
- patch("boto3.Session", return_value=mock_session),
- patch.object(
- AgentCoreSearchConfig.__mro__[2], # BaseAWSLLM
- "_sign_request",
- return_value=({}, b"{}"),
- ) as mock_base_sign,
- ):
- config.sign_request(
- headers={},
- optional_params={},
- request_data={"jsonrpc": "2.0"},
- api_base="https://gateway.internal.example.com/mcp",
- )
- assert mock_base_sign.call_args.kwargs["optional_params"]["aws_region_name"] == "eu-west-1"
+ try:
+ with (
+ patch("boto3.Session", return_value=mock_session),
+ patch.object(
+ AgentCoreSearchConfig.__mro__[2], # BaseAWSLLM
+ "_sign_request",
+ return_value=({}, b"{}"),
+ ) as mock_base_sign,
+ ):
+ config.sign_request(
+ headers={},
+ optional_params={},
+ request_data={"jsonrpc": "2.0"},
+ api_base=custom_url,
+ )
+ assert mock_base_sign.call_args.kwargs["optional_params"]["aws_region_name"] == "eu-west-1"
+ finally:
+ os.environ.pop("AGENTCORE_GATEWAY_URL", None)
def test_sign_request_passes_explicit_aws_credentials(self):
"""Explicit aws_* params (e.g. from a proxy search_tools entry) reach the signer."""
@@ -434,7 +523,11 @@ class TestAgentCoreSearchEdgeCases:
assert getattr(err, "status_code", None) == 503
assert "boom" in str(err)
- def test_search_cost_lookup_is_mapped(self):
+ def test_search_cost_lookup_is_mapped(self, monkeypatch):
+ """Assert against the map in this checkout: the remote cost map litellm loads by
+ default only carries providers already released."""
+ from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
from litellm.search.cost_calculator import search_provider_cost_per_query
+ monkeypatch.setattr(litellm, "model_cost", GetModelCostMap.load_local_model_cost_map())
assert search_provider_cost_per_query(model="agentcore/search", custom_llm_provider="agentcore") == (0.0, 0.0)
From 49dca4996c2096d8d5bba1ab5e1f10028f221abf Mon Sep 17 00:00:00 2001
From: yassin
Date: Wed, 19 Aug 2026 19:31:19 +0000
Subject: [PATCH 27/88] fix(search): point the AgentCore region error at
AWS_DEFAULT_REGION
boto3's session resolution ignores AWS_REGION, so an operator following the
old message still hit the same failure.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
litellm/llms/bedrock/search/transformation.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/litellm/llms/bedrock/search/transformation.py b/litellm/llms/bedrock/search/transformation.py
index 85c5818031b..80e20d00ff9 100644
--- a/litellm/llms/bedrock/search/transformation.py
+++ b/litellm/llms/bedrock/search/transformation.py
@@ -331,7 +331,7 @@ class AgentCoreSearchConfig(BaseSearchConfig, BaseAWSLLM):
return configured_region
raise ValueError(
f"Cannot derive the SigV4 signing region from api_base '{api_base}' "
- "or the AWS configuration chain. Set aws_region_name (or AWS_REGION / "
+ "or the AWS configuration chain. Set aws_region_name (or AWS_DEFAULT_REGION / "
"a profile region) to the gateway's region when using a custom hostname."
)
From a613773fca0161df9ab879f12f02caaa7464af99 Mon Sep 17 00:00:00 2001
From: tin-berri
Date: Wed, 19 Aug 2026 14:02:15 -0700
Subject: [PATCH 28/88] feat(auto-router)!: scope shadow eval jobs to multiple
keys (#37251)
* feat(auto-router): scope shadow eval jobs to multiple keys
A shadow eval job now covers a set of keys instead of exactly one, and each
key carries its own max_turns budget, so one key exhausting its budget leaves
its siblings sampling. The existing job row already is the per-key unit
(api_key_id, max_turns, stopped_at, and the one-active-per-key-and-direction
partial unique index all live on it), so multi-key is grouping rather than
schema surgery: a new group_id column ties N sibling rows written atomically
by one create_many, the API's job id becomes the group id, and pre-existing
jobs backfill group_id = id so their ids keep resolving. The sampler hot path
is untouched; its test file has a zero-line diff
Results come back pooled plus a per-key breakdown and responses list every key
with its own budget, stop state and read-time labels. The dashboard is adapted
minimally to the new shapes (the picker stays single-key and submits a one-key
list); the multi-select picker and per-key table land in the stacked UI PR
* fix(shadow_eval): derive completed from spent budgets and record operator stops
* fix(shadow_eval): stamp stops atomically and freeze counts at the stamp
The stop endpoint wrote stopped_by and stopped_at as two separate updates, so
a failure between them left a job reading stopped while its unstamped legs
kept sampling, and the retry got 400 already stopped. One UPDATE now stamps
stopped_by and every missing stopped_at together, preserving the stopped_at a
leg earned from its own budget via COALESCE
Attempt counts now exclude attempts that land after a leg's stopped_at, so an
in-flight attempt finishing just after an operator stop can never push a
legacy pre-stopped_by job over its budget and flip it from stopped to
completed at read time
* fix(shadow_eval): backfill stopped_by so legacy stops never read as completions
* chore(ui): regenerate api types for the shadow eval stop fields
* fix(shadow_eval): let the stop statement pick one winner under racing stops
Two operators can both pass the derived-status guard in the race window. The
stop UPDATE now claims only legs with stopped_by still null and the endpoint
judges by its row count, so exactly one caller ever gets the 200 and the loser
gets the same already-stopped 400 a late caller gets
* refactor(shadow_eval): make the stop statement the whole state machine
The status guard ran before the UPDATE, so a stop racing the last budgeted
attempt still claimed the job and it read stopped forever instead of
completed. The statement now claims the job only while a leg still samples
inside the window with no stop recorded, and the endpoint reads once after
writing: a racing operator, a same-instant budget spend, and a repeat stop all
get the 400 naming the status the job actually holds. The pre-write guard and
the hand-built response go away
* chore(ui): regenerate api types for the stop route description
---
.../migration.sql | 7 +
.../migration.sql | 5 +
.../litellm_proxy_extras/schema.prisma | 30 +-
.../auto_router_endpoints.py | 378 ++++++---
litellm/proxy/schema.prisma | 30 +-
.../auto_router_endpoints.py | 103 ++-
schema.prisma | 30 +-
.../test_auto_router_endpoints.py | 717 ++++++++++++++----
.../_components/ShadowEvalSection.test.tsx | 43 +-
.../_components/ShadowEvalSection.tsx | 26 +-
.../_components/useShadowEval.ts | 1 +
ui/litellm-dashboard/src/lib/http/schema.d.ts | 118 ++-
12 files changed, 1117 insertions(+), 371 deletions(-)
create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260817000000_shadow_eval_multi_key/migration.sql
create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260818224500_add_shadow_eval_stopped_by/migration.sql
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/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/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/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/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/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
index 77149457e82..3fd023552f5 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
@@ -4,6 +4,7 @@ Unit tests for auto router management endpoints
import os
import sys
+from pathlib import Path
import pytest
from fastapi import HTTPException
@@ -325,9 +326,7 @@ class TestAutoRouterBenchmarks:
from litellm.proxy.management_endpoints.auto_router_endpoints import _benchmark_totals
totals = _benchmark_totals(self.ROW)
- bucket_hits = (
- totals.cache.same_model.hits + totals.cache.first_visit.hits + totals.cache.return_to_tier.hits
- )
+ bucket_hits = totals.cache.same_model.hits + totals.cache.first_visit.hits + totals.cache.return_to_tier.hits
assert bucket_hits == 27
assert totals.cache.hit_rate_pct == pytest.approx(100.0 * 28 / 38, abs=0.1)
@@ -490,7 +489,7 @@ from litellm.proxy.management_endpoints.auto_router_endpoints import (
start_shadow_eval,
stop_shadow_eval_job,
)
-from litellm.types.management_endpoints.auto_router_endpoints import ShadowEvalJobResponse, StartShadowEvalRequest
+from litellm.types.management_endpoints.auto_router_endpoints import StartShadowEvalRequest
VIEWER = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, api_key="sk-view", user_id="viewer")
NON_ADMIN = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-user", user_id="user")
@@ -507,19 +506,23 @@ def _shadow_router() -> MagicMock:
return router
-def _job_record(**overrides: object) -> MagicMock:
- """Spec'd like a real prisma row: only the table's columns exist as attributes, so
- from_attributes validation falls back to model defaults for everything else."""
+def _leg_record(**overrides: object) -> MagicMock:
+ """Spec'd like a real prisma row: only the table's columns exist as attributes. One
+ row is one key's leg of a job; legs sharing group_id are one job."""
defaults = {
- "id": "job-1",
+ "id": "leg-1",
+ "group_id": "job-1",
"api_key_id": "key-hash",
"router_name": "my-router",
+ "direction": "forward",
+ "baseline_model": None,
"judge_model": "anthropic/claude-sonnet-5",
"shadow_percentage": 10.0,
"max_turns": 200,
"created_at": datetime(2026, 8, 11, tzinfo=timezone.utc),
"ends_at": datetime.now(timezone.utc) + timedelta(days=7),
"stopped_at": None,
+ "stopped_by": None,
}
fields = {**defaults, **overrides}
record = MagicMock(spec=list(fields))
@@ -538,23 +541,90 @@ def _key_record(
return record
-def _shadow_prisma(active_job=None, agg_rows=None) -> MagicMock:
+def _shadow_prisma(legs=(), agg_rows=None, by_leg_rows=None, known_keys=("key-hash", "key-hash-2")) -> MagicMock:
+ """The job-table fake honours the filters it is handed, so a read that forgets
+ stopped_at sees rows the partial index would have released, one that forgets
+ direction sees the opposite-direction legs a key may hold at the same time, and a
+ group read that matched on a leg id would come back empty."""
prisma = MagicMock()
- prisma.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=_key_record())
- prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[_key_record()])
- prisma.db.execute_raw = AsyncMock(return_value=0)
- prisma.db.litellm_shadowevaljob.find_first = AsyncMock(return_value=active_job)
- prisma.db.litellm_shadowevaljob.find_unique = AsyncMock(return_value=None)
- prisma.db.litellm_shadowevaljob.find_many = AsyncMock(return_value=[])
- prisma.db.litellm_shadowevaljob.create = AsyncMock(return_value=_job_record())
- prisma.db.litellm_shadowevaljob.update = AsyncMock(
- return_value=_job_record(stopped_at=datetime.now(timezone.utc))
- )
+ prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[_key_record(token) for token in known_keys])
+ async def execute_raw(sql: str, *params: object):
+ if "SET stopped_by" in sql:
+ group = [row for row in stored if row.group_id == params[0]]
+ counts = {row["job_id"]: row["attempt_count"] for row in prisma.attempt_rows}
+ sampling = any(row.stopped_at is None and counts.get(row.id, 0) < row.max_turns for row in group)
+ window_open = bool(group) and group[0].ends_at > datetime.now(timezone.utc)
+ claimable = [row for row in group if row.stopped_by is None]
+ if not (claimable and sampling and window_open):
+ return 0
+ for row in claimable:
+ row.stopped_by = params[1]
+ if row.stopped_at is None:
+ row.stopped_at = datetime.fromisoformat(str(params[2])).replace(tzinfo=timezone.utc)
+ return len(claimable)
+ return 0
+
+ prisma.db.execute_raw = AsyncMock(side_effect=execute_raw)
+ stored = legs if isinstance(legs, list) else list(legs)
+
+ async def find_many_legs(where=None, **_: object):
+ current = list(stored)
+ w = dict(where or {})
+ if "api_key_id" in w:
+ wanted = w["api_key_id"]["in"] if isinstance(w["api_key_id"], dict) else [w["api_key_id"]]
+ current = [row for row in current if row.api_key_id in wanted]
+ if "direction" in w:
+ current = [row for row in current if row.direction == w["direction"]]
+ if "stopped_at" in w:
+ current = [row for row in current if row.stopped_at is w["stopped_at"]]
+ if "group_id" in w:
+ wanted = w["group_id"]["in"] if isinstance(w["group_id"], dict) else [w["group_id"]]
+ current = [row for row in current if row.group_id in wanted]
+ return current
+
+ def newest_groups(rows, limit):
+ latest: dict = {}
+ for row in rows:
+ if row.group_id not in latest or row.created_at > latest[row.group_id]:
+ latest[row.group_id] = row.created_at
+ ordered = sorted(latest, key=lambda group_id: latest[group_id], reverse=True)
+ return ordered[: int(limit)]
+
+ def leg_dict(row):
+ fields = (
+ "id",
+ "group_id",
+ "api_key_id",
+ "router_name",
+ "direction",
+ "baseline_model",
+ "judge_model",
+ "shadow_percentage",
+ "max_turns",
+ "created_at",
+ "ends_at",
+ "stopped_at",
+ "stopped_by",
+ )
+ return {field: getattr(row, field) for field in fields}
+
+ prisma.db.litellm_shadowevaljob.find_many = AsyncMock(side_effect=find_many_legs)
+ prisma.db.litellm_shadowevaljob.create_many = AsyncMock(return_value=1)
+ prisma.db.litellm_shadowevaljob.update_many = AsyncMock(return_value=1)
prisma.db.litellm_shadowevalattempt.find_first = AsyncMock(return_value=None)
+ prisma.attempt_rows = []
async def query_raw(sql: str, *params: object):
+ if "AS attempt_count" in sql:
+ return prisma.attempt_rows
+ if "GROUP BY group_id" in sql:
+ scoped = [row for row in stored if "api_key_id = $2" not in sql or row.api_key_id == params[1]]
+ keep = set(newest_groups(scoped, params[0]))
+ return [leg_dict(row) for row in stored if row.group_id in keep]
if "FILTER (WHERE outcome != 'error')::int AS judged_count" in sql:
return [{"judged_count": 10, "error_count": 2, "judge_spend": 0.031}]
+ if "SELECT job_id AS grp" in sql:
+ return by_leg_rows if by_leg_rows is not None else []
return agg_rows if agg_rows is not None else []
prisma.db.query_raw = AsyncMock(side_effect=query_raw)
@@ -563,7 +633,7 @@ def _shadow_prisma(active_job=None, agg_rows=None) -> MagicMock:
def _start_request(**overrides: object) -> StartShadowEvalRequest:
payload = {
- "api_key_id": "key-hash",
+ "api_key_ids": ("key-hash",),
"router_name": "my-router",
"shadow_percentage": 10.0,
"judge_model": "anthropic/claude-sonnet-5",
@@ -575,44 +645,55 @@ def _start_request(**overrides: object) -> StartShadowEvalRequest:
@pytest.mark.asyncio
-async def test_start_shadow_eval_creates_job_and_frees_expired_or_exhausted_ones(monkeypatch: pytest.MonkeyPatch):
- """Expiry and turn-budget exhaustion both end sampling on their own; either must
- release the key's slot in the active-job index so a new eval can start."""
+async def test_start_shadow_eval_writes_one_leg_per_key_in_one_statement(monkeypatch: pytest.MonkeyPatch):
+ """N keys become N sibling rows sharing group_id and identical config, written by a
+ single create_many so a unique-index loser rolls back the whole claim, and expiry or
+ budget exhaustion frees every requested key's slot first."""
import litellm.proxy.proxy_server as proxy_server
prisma = _shadow_prisma()
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
- response = await start_shadow_eval(_start_request(), ADMIN)
+ response = await start_shadow_eval(_start_request(api_key_ids=("key-hash", "key-hash-2")), ADMIN)
- assert response.status == "running"
- assert response.max_turns == 200
- assert response.judged_count is None
- sweep_sql, sweep_key = prisma.db.execute_raw.call_args.args
+ sweep_sql, sweep_keys = prisma.db.execute_raw.call_args.args
assert "stopped_at IS NULL" in sweep_sql
- assert "ends_at <= NOW()" in sweep_sql
+ assert "j.ends_at <= (NOW() AT TIME ZONE 'utc')" in sweep_sql
+ assert "SET stopped_at = (NOW() AT TIME ZONE 'utc')" in sweep_sql
assert ">= j.max_turns" in sweep_sql
- assert sweep_key == "key-hash"
- create_data = prisma.db.litellm_shadowevaljob.create.call_args.kwargs["data"]
- assert create_data["api_key_id"] == "key-hash"
- assert create_data["created_by"] == "admin"
- assert "status" not in create_data
+ assert "j.api_key_id = ANY($1::text[])" in sweep_sql
+ assert sweep_keys == ["key-hash", "key-hash-2"]
+ prisma.db.litellm_shadowevaljob.create_many.assert_awaited_once()
+ rows = prisma.db.litellm_shadowevaljob.create_many.call_args.kwargs["data"]
+ assert [row["api_key_id"] for row in rows] == ["key-hash", "key-hash-2"]
+ assert len({frozenset((k, v) for k, v in row.items() if k != "api_key_id") for row in rows}) == 1
+ assert len({row["group_id"] for row in rows}) == 1
+ assert all(row["max_turns"] == 200 and row["created_by"] == "admin" for row in rows)
+ assert all("status" not in row and "id" not in row for row in rows)
+ assert response.job_id == rows[0]["group_id"]
+ assert response.status == "running"
+ assert response.judged_count is None
+ assert [(key.api_key_id, key.max_turns, key.key_alias) for key in response.keys] == [
+ ("key-hash", 200, "prod-alpha"),
+ ("key-hash-2", 200, "prod-alpha"),
+ ]
@pytest.mark.asyncio
@pytest.mark.parametrize(
- "caller,request_overrides,active,expected_status",
+ "caller,request_overrides,claimed,expected_status",
[
- (NON_ADMIN, {}, None, 403),
- (VIEWER, {}, None, 403),
- (ADMIN, {"router_name": "not-a-router"}, None, 400),
- (ADMIN, {"judge_model": "not/a real model!"}, None, 400),
- (ADMIN, {"judge_model": "my-router"}, None, 400),
- (ADMIN, {}, "active", 409),
- (ADMIN, {"direction": "reverse", "baseline_model": "my-router"}, None, 400),
- (ADMIN, {"direction": "reverse", "baseline_model": "not/a real model!"}, None, 400),
- (ADMIN, {"direction": "reverse", "baseline_model": "openai/gpt-4o", "router_name": "not-a-router"}, None, 400),
+ (NON_ADMIN, {}, (), 403),
+ (VIEWER, {}, (), 403),
+ (ADMIN, {"router_name": "not-a-router"}, (), 400),
+ (ADMIN, {"judge_model": "not/a real model!"}, (), 400),
+ (ADMIN, {"judge_model": "my-router"}, (), 400),
+ (ADMIN, {}, ("key-hash",), 409),
+ (ADMIN, {"api_key_ids": ("key-hash", "key-hash-2")}, ("key-hash-2",), 409),
+ (ADMIN, {"direction": "reverse", "baseline_model": "my-router"}, (), 400),
+ (ADMIN, {"direction": "reverse", "baseline_model": "not/a real model!"}, (), 400),
+ (ADMIN, {"direction": "reverse", "baseline_model": "openai/gpt-4o", "router_name": "not-a-router"}, (), 400),
],
ids=[
"non-admin",
@@ -621,23 +702,143 @@ async def test_start_shadow_eval_creates_job_and_frees_expired_or_exhausted_ones
"unresolvable-judge",
"router-as-judge",
"already-active",
+ "one-of-several-keys-already-active",
"router-as-baseline",
"unresolvable-baseline",
"reverse-still-needs-an-auto-router",
],
)
async def test_start_shadow_eval_rejections(
- monkeypatch: pytest.MonkeyPatch, caller, request_overrides, active, expected_status
+ monkeypatch: pytest.MonkeyPatch, caller, request_overrides, claimed, expected_status
):
import litellm.proxy.proxy_server as proxy_server
- prisma = _shadow_prisma(active_job=_job_record() if active else None)
+ prisma = _shadow_prisma(legs=[_leg_record(id=f"leg-{key}", group_id="job-7", api_key_id=key) for key in claimed])
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
with pytest.raises(HTTPException) as exc:
await start_shadow_eval(_start_request(**request_overrides), caller)
assert exc.value.status_code == expected_status
+ prisma.db.litellm_shadowevaljob.create_many.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_start_shadow_eval_names_the_busy_key_and_its_job(monkeypatch: pytest.MonkeyPatch):
+ """A key busy elsewhere blocks the whole start rather than being silently dropped from
+ it, and the 409 names which key and which job so the caller can stop or drop it."""
+ import litellm.proxy.proxy_server as proxy_server
+
+ prisma = _shadow_prisma(legs=[_leg_record(id="leg-b", group_id="job-7", api_key_id="key-hash-2")])
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+ monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
+
+ with pytest.raises(HTTPException) as exc:
+ await start_shadow_eval(_start_request(api_key_ids=("key-hash", "key-hash-2")), ADMIN)
+ assert exc.value.status_code == 409
+ assert "key-hash-2 (job job-7)" in exc.value.detail
+
+
+@pytest.mark.asyncio
+async def test_start_shadow_eval_reuses_a_key_whose_previous_job_already_stopped(monkeypatch: pytest.MonkeyPatch):
+ """The claim is held by unstopped legs only, matching the partial unique index. A read
+ that forgets that would strand every key that has ever finished a job."""
+ import litellm.proxy.proxy_server as proxy_server
+
+ prisma = _shadow_prisma(legs=[_leg_record(group_id="job-7", stopped_at=datetime.now(timezone.utc))])
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+ monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
+
+ job = await start_shadow_eval(_start_request(), ADMIN)
+
+ assert job.status == "running"
+ prisma.db.litellm_shadowevaljob.create_many.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_start_shadow_eval_reverse_records_its_arms_and_holds_its_own_slot(monkeypatch: pytest.MonkeyPatch):
+ """The two directions ask opposite questions of the same key, so a forward job holding
+ the slot must not block a reverse one. The second reverse start still 409s."""
+ import litellm.proxy.proxy_server as proxy_server
+
+ legs = [_leg_record(group_id="job-fwd")]
+ prisma = _shadow_prisma(legs=legs)
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+ monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
+
+ reverse = _start_request(direction="reverse", baseline_model="openai/gpt-4o")
+ response = await start_shadow_eval(reverse, ADMIN)
+
+ assert (response.direction, response.baseline_model) == ("reverse", "openai/gpt-4o")
+ rows = prisma.db.litellm_shadowevaljob.create_many.call_args.kwargs["data"]
+ assert rows[0]["direction"] == "reverse"
+ assert rows[0]["baseline_model"] == "openai/gpt-4o"
+
+ legs.append(_leg_record(id="leg-2", group_id="job-rev", direction="reverse"))
+ with pytest.raises(HTTPException) as exc:
+ await start_shadow_eval(reverse, ADMIN)
+ assert exc.value.status_code == 409
+
+
+@pytest.mark.asyncio
+async def test_start_shadow_eval_forward_leaves_the_baseline_column_empty(monkeypatch: pytest.MonkeyPatch):
+ import litellm.proxy.proxy_server as proxy_server
+
+ prisma = _shadow_prisma()
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+ monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
+
+ await start_shadow_eval(_start_request(), ADMIN)
+
+ rows = prisma.db.litellm_shadowevaljob.create_many.call_args.kwargs["data"]
+ assert rows[0]["direction"] == "forward"
+ assert rows[0]["baseline_model"] is None
+
+
+@pytest.mark.asyncio
+async def test_start_shadow_eval_rejects_keys_this_proxy_does_not_know(monkeypatch: pytest.MonkeyPatch):
+ """A typo'd api_key_id would otherwise create a leg no traffic can ever match. Every
+ unknown key is named at once, so a caller passing several fixes them in one round."""
+ import litellm.proxy.proxy_server as proxy_server
+
+ prisma = _shadow_prisma(known_keys=("key-hash",))
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+ monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
+
+ with pytest.raises(HTTPException) as exc:
+ await start_shadow_eval(_start_request(api_key_ids=("key-hash", "typo-a", "typo-b")), ADMIN)
+ assert exc.value.status_code == 400
+ assert "typo-a, typo-b" in exc.value.detail
+ assert "key-hash," not in exc.value.detail
+ prisma.db.litellm_shadowevaljob.create_many.assert_not_called()
+
+
+def test_start_shadow_eval_request_dedupes_and_bounds_the_key_set():
+ """A key named twice would collide with itself on the one-active-per-key index, a job
+ scoping no key samples nothing, and the key-count cap bounds every downstream read."""
+ assert _start_request(api_key_ids=("a", "b", "a")).api_key_ids == ("a", "b")
+ assert len(_start_request(api_key_ids=tuple(f"k{i}" for i in range(100))).api_key_ids) == 100
+ with pytest.raises(ValidationError):
+ _start_request(api_key_ids=())
+ with pytest.raises(ValidationError):
+ _start_request(api_key_ids=tuple(f"k{i}" for i in range(101)))
+
+
+@pytest.mark.asyncio
+async def test_start_shadow_eval_concurrent_unique_violation_is_a_409(monkeypatch: pytest.MonkeyPatch):
+ import litellm.proxy.proxy_server as proxy_server
+ from prisma.errors import UniqueViolationError
+
+ prisma = _shadow_prisma()
+ prisma.db.litellm_shadowevaljob.create_many = AsyncMock(
+ side_effect=UniqueViolationError(MagicMock(message="unique constraint"))
+ )
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+ monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
+
+ with pytest.raises(HTTPException) as exc:
+ await start_shadow_eval(_start_request(), ADMIN)
+ assert exc.value.status_code == 409
@pytest.mark.parametrize(
@@ -657,97 +858,25 @@ def test_start_request_pins_baseline_model_to_reverse(overrides):
@pytest.mark.asyncio
-async def test_start_shadow_eval_reverse_records_its_arms_and_holds_its_own_slot(monkeypatch: pytest.MonkeyPatch):
- """The two directions ask opposite questions of the same key, so a forward job holding
- the slot must not block a reverse one. The second reverse start still 409s."""
- import litellm.proxy.proxy_server as proxy_server
-
- prisma = _shadow_prisma()
- active = {"forward": _job_record()}
- prisma.db.litellm_shadowevaljob.find_first = AsyncMock(
- side_effect=lambda where, **_: active.get(str(where.get("direction")))
- )
- prisma.db.litellm_shadowevaljob.create = AsyncMock(
- return_value=_job_record(direction="reverse", baseline_model="openai/gpt-4o")
- )
- monkeypatch.setattr(proxy_server, "prisma_client", prisma)
- monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
-
- reverse = _start_request(direction="reverse", baseline_model="openai/gpt-4o")
- response = await start_shadow_eval(reverse, ADMIN)
-
- assert (response.direction, response.baseline_model) == ("reverse", "openai/gpt-4o")
- create_data = prisma.db.litellm_shadowevaljob.create.call_args.kwargs["data"]
- assert create_data["direction"] == "reverse"
- assert create_data["baseline_model"] == "openai/gpt-4o"
-
- active["reverse"] = _job_record(id="job-2", direction="reverse")
- with pytest.raises(HTTPException) as exc:
- await start_shadow_eval(reverse, ADMIN)
- assert exc.value.status_code == 409
-
-
-@pytest.mark.asyncio
-async def test_start_shadow_eval_forward_leaves_the_baseline_column_empty(monkeypatch: pytest.MonkeyPatch):
- import litellm.proxy.proxy_server as proxy_server
-
- prisma = _shadow_prisma()
- monkeypatch.setattr(proxy_server, "prisma_client", prisma)
- monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
-
- await start_shadow_eval(_start_request(), ADMIN)
-
- create_data = prisma.db.litellm_shadowevaljob.create.call_args.kwargs["data"]
- assert create_data["direction"] == "forward"
- assert create_data["baseline_model"] is None
-
-
-@pytest.mark.asyncio
-async def test_start_shadow_eval_rejects_a_key_this_proxy_does_not_know(monkeypatch: pytest.MonkeyPatch):
- """A typo'd api_key_id would otherwise create a job no traffic can ever match."""
- import litellm.proxy.proxy_server as proxy_server
-
- prisma = _shadow_prisma()
- prisma.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
- monkeypatch.setattr(proxy_server, "prisma_client", prisma)
- monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
-
- with pytest.raises(HTTPException) as exc:
- await start_shadow_eval(_start_request(), ADMIN)
- assert exc.value.status_code == 400
- assert "not a key on this proxy" in exc.value.detail
-
-
-@pytest.mark.asyncio
-async def test_start_shadow_eval_concurrent_unique_violation_is_a_409(monkeypatch: pytest.MonkeyPatch):
- import litellm.proxy.proxy_server as proxy_server
- from prisma.errors import UniqueViolationError
-
- prisma = _shadow_prisma()
- prisma.db.litellm_shadowevaljob.create = AsyncMock(
- side_effect=UniqueViolationError(MagicMock(message="unique constraint"))
- )
- monkeypatch.setattr(proxy_server, "prisma_client", prisma)
- monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
-
- with pytest.raises(HTTPException) as exc:
- await start_shadow_eval(_start_request(), ADMIN)
- assert exc.value.status_code == 409
-
-
-@pytest.mark.asyncio
-async def test_get_shadow_eval_job_derives_counts_spend_and_stratified_results(monkeypatch: pytest.MonkeyPatch):
+async def test_get_shadow_eval_job_pools_counts_and_slices_results_per_key(monkeypatch: pytest.MonkeyPatch):
+ """One read answers for every leg: totals and stratifications aggregate over the
+ group's leg ids, and the by-key slice maps each leg id back to its key hash."""
import litellm.proxy.proxy_server as proxy_server
tier_rows = [
{"grp": "SIMPLE", "turn_count": 8, "real_wins": 2, "shadow_wins": 4, "ties": 2, "avg_confidence": 0.8},
{"grp": "REASONING", "turn_count": 2, "real_wins": 2, "shadow_wins": 0, "ties": 0, "avg_confidence": 0.9},
]
- prisma = _shadow_prisma(agg_rows=tier_rows)
- prisma.db.litellm_shadowevaljob.find_unique = AsyncMock(return_value=_job_record())
- prisma.db.litellm_shadowevalattempt.find_first = AsyncMock(
- return_value=MagicMock(error="judge call failed: boom")
+ leg_rows = [
+ {"grp": "leg-1", "turn_count": 6, "real_wins": 1, "shadow_wins": 4, "ties": 1, "avg_confidence": 0.7},
+ {"grp": "leg-2", "turn_count": 4, "real_wins": 3, "shadow_wins": 0, "ties": 1, "avg_confidence": 0.6},
+ ]
+ prisma = _shadow_prisma(
+ legs=[_leg_record(), _leg_record(id="leg-2", api_key_id="key-hash-2", max_turns=50)],
+ agg_rows=tier_rows,
+ by_leg_rows=leg_rows,
)
+ prisma.db.litellm_shadowevalattempt.find_first = AsyncMock(return_value=MagicMock(error="judge call failed: boom"))
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
response = await get_shadow_eval_job("job-1", VIEWER)
@@ -762,6 +891,13 @@ async def test_get_shadow_eval_job_derives_counts_spend_and_stratified_results(m
assert response.results.by_tier[0].shadow_win_rate_pct == 50.0
assert response.results.overall_shadow_win_rate_pct == 40.0
assert response.results.overall_tie_rate_pct == 20.0
+ assert [(s.group, s.turn_count) for s in response.results.by_key] == [("key-hash", 6), ("key-hash-2", 4)]
+ assert response.results.by_key[0].shadow_win_rate_pct == 66.7
+ assert [(key.api_key_id, key.max_turns) for key in response.keys] == [("key-hash", 200), ("key-hash-2", 50)]
+ totals_args = [call.args for call in prisma.db.query_raw.await_args_list if "judged_count" in call.args[0]]
+ assert totals_args == [(totals_args[0][0], ["leg-1", "leg-2"])]
+ error_where = prisma.db.litellm_shadowevalattempt.find_first.call_args.kwargs["where"]
+ assert error_where == {"job_id": {"in": ["leg-1", "leg-2"]}, "outcome": "error"}
@pytest.mark.asyncio
@@ -780,79 +916,326 @@ async def test_get_shadow_eval_job_404s_and_gates_on_role(monkeypatch: pytest.Mo
@pytest.mark.asyncio
-async def test_list_shadow_eval_jobs_returns_derived_status_without_aggregates(monkeypatch: pytest.MonkeyPatch):
+async def test_list_shadow_eval_jobs_collapses_legs_into_jobs_newest_first(monkeypatch: pytest.MonkeyPatch):
+ """A job over two keys is one list entry with both keys, not two entries, and a job
+ whose keys all stopped reads stopped while a half-stopped one still runs."""
import litellm.proxy.proxy_server as proxy_server
- prisma = _shadow_prisma()
- prisma.db.litellm_shadowevaljob.find_many = AsyncMock(
- return_value=[
- _job_record(),
- _job_record(id="job-2", ends_at=datetime.now(timezone.utc) - timedelta(days=1)),
- _job_record(id="job-3", stopped_at=datetime.now(timezone.utc)),
+ stamp = datetime.now(timezone.utc)
+ prisma = _shadow_prisma(
+ legs=[
+ _leg_record(created_at=datetime(2026, 8, 13, tzinfo=timezone.utc)),
+ _leg_record(
+ id="leg-2",
+ api_key_id="key-hash-2",
+ stopped_at=stamp,
+ created_at=datetime(2026, 8, 13, tzinfo=timezone.utc),
+ ),
+ _leg_record(
+ id="leg-3",
+ group_id="job-2",
+ stopped_at=stamp,
+ created_at=datetime(2026, 8, 12, tzinfo=timezone.utc),
+ ),
+ _leg_record(
+ id="leg-4",
+ group_id="job-3",
+ ends_at=datetime.now(timezone.utc) - timedelta(days=1),
+ created_at=datetime(2026, 8, 11, tzinfo=timezone.utc),
+ ),
]
)
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
jobs = await list_shadow_eval_jobs(VIEWER, api_key_id=None, limit=50)
- assert [job.status for job in jobs] == ["running", "completed", "stopped"]
- swept = ShadowEvalJobResponse.model_validate(
- _job_record(
- id="job-4",
- ends_at=datetime.now(timezone.utc) - timedelta(days=1),
- stopped_at=datetime.now(timezone.utc),
- ),
- from_attributes=True,
- )
- assert swept.status == "completed"
+ assert [(job.job_id, job.status) for job in jobs] == [
+ ("job-1", "running"),
+ ("job-2", "stopped"),
+ ("job-3", "completed"),
+ ]
+ assert [key.api_key_id for key in jobs[0].keys] == ["key-hash", "key-hash-2"]
assert all(job.judged_count is None and job.results is None for job in jobs)
- assert prisma.db.query_raw.await_count == 0
+ legs_sql, legs_limit = prisma.db.query_raw.await_args_list[0].args
+ assert "GROUP BY group_id ORDER BY MAX(created_at) DESC LIMIT $1::int" in legs_sql
+ assert legs_limit == 50
+ counts_sql, _ = prisma.db.query_raw.await_args_list[1].args
+ assert "AS attempt_count" in counts_sql
+ assert "j.stopped_at IS NULL OR a.created_at <= j.stopped_at" in counts_sql
+ assert prisma.db.query_raw.await_count == 2
+ prisma.db.litellm_shadowevaljob.find_many.assert_not_called()
@pytest.mark.asyncio
-async def test_shadow_eval_responses_name_the_shadowed_key(monkeypatch: pytest.MonkeyPatch):
+async def test_list_shadow_eval_jobs_filters_to_jobs_containing_the_key(monkeypatch: pytest.MonkeyPatch):
+ """The filter matches a key anywhere in a job's key set and still returns the whole
+ job, sibling keys included."""
import litellm.proxy.proxy_server as proxy_server
- prisma = _shadow_prisma()
- prisma.db.litellm_shadowevaljob.find_many = AsyncMock(
- return_value=[_job_record(), _job_record(id="job-2", api_key_id="deleted-key-hash")]
+ prisma = _shadow_prisma(
+ legs=[
+ _leg_record(),
+ _leg_record(id="leg-2", api_key_id="key-hash-2"),
+ _leg_record(id="leg-3", group_id="job-2", api_key_id="key-hash-2"),
+ _leg_record(id="leg-4", group_id="job-3"),
+ ]
+ )
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+
+ jobs = await list_shadow_eval_jobs(VIEWER, api_key_id="key-hash-2", limit=50)
+
+ assert [job.job_id for job in jobs] == ["job-1", "job-2"]
+ assert [key.api_key_id for key in jobs[0].keys] == ["key-hash", "key-hash-2"]
+
+
+@pytest.mark.parametrize(
+ ("stopped_flags", "days_left", "expected"),
+ [
+ ((False, False), 7, "running"),
+ ((True, False), 7, "running"),
+ ((True, True), 7, "stopped"),
+ ((True, True), -1, "completed"),
+ ((False, False), -1, "completed"),
+ ],
+)
+@pytest.mark.asyncio
+async def test_job_status_runs_until_every_key_stops_and_completed_outranks_stopped(
+ monkeypatch: pytest.MonkeyPatch, stopped_flags: tuple[bool, ...], days_left: int, expected: str
+):
+ import litellm.proxy.proxy_server as proxy_server
+
+ stamp = datetime.now(timezone.utc)
+ prisma = _shadow_prisma(
+ legs=[
+ _leg_record(
+ id=f"leg-{index}",
+ api_key_id=f"key-{index}",
+ stopped_at=stamp if stopped else None,
+ ends_at=datetime.now(timezone.utc) + timedelta(days=days_left),
+ )
+ for index, stopped in enumerate(stopped_flags)
+ ]
+ )
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+
+ jobs = await list_shadow_eval_jobs(VIEWER, api_key_id=None, limit=50)
+
+ assert [job.status for job in jobs] == [expected]
+
+
+@pytest.mark.asyncio
+async def test_list_reads_completed_once_every_key_spends_its_budget(monkeypatch: pytest.MonkeyPatch):
+ """A job whose keys all exhausted their turn budgets stopped sampling on its own, so
+ it must read completed on the very next list, before any sweep stamps its legs; one
+ key under budget keeps the whole job running. An operator starting an unrelated eval
+ must never look like it terminated a finished one."""
+ import litellm.proxy.proxy_server as proxy_server
+
+ prisma = _shadow_prisma(
+ legs=[
+ _leg_record(max_turns=5),
+ _leg_record(id="leg-2", api_key_id="key-hash-2", max_turns=5),
+ _leg_record(id="leg-3", group_id="job-2", api_key_id="key-hash", max_turns=5),
+ _leg_record(id="leg-4", group_id="job-2", api_key_id="key-hash-2", max_turns=5),
+ ]
+ )
+ prisma.attempt_rows = [
+ {"job_id": "leg-1", "attempt_count": 5},
+ {"job_id": "leg-2", "attempt_count": 6},
+ {"job_id": "leg-3", "attempt_count": 5},
+ {"job_id": "leg-4", "attempt_count": 3},
+ ]
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+
+ jobs = await list_shadow_eval_jobs(VIEWER, api_key_id=None, limit=50)
+
+ by_id = {job.job_id: job for job in jobs}
+ assert by_id["job-1"].status == "completed"
+ assert all(key.stopped_at is None for key in by_id["job-1"].keys)
+ assert by_id["job-2"].status == "running"
+ assert {key.api_key_id: key.attempt_count for key in by_id["job-2"].keys} == {"key-hash": 5, "key-hash-2": 3}
+
+
+@pytest.mark.asyncio
+async def test_recorded_operator_stop_outranks_budget_arithmetic(monkeypatch: pytest.MonkeyPatch):
+ """A detached attempt can land around the stop and push the raw count past the
+ budget; the recorded stopped_by must keep the job reading stopped regardless."""
+ import litellm.proxy.proxy_server as proxy_server
+
+ stamp = datetime.now(timezone.utc)
+ prisma = _shadow_prisma(legs=[_leg_record(max_turns=5, stopped_at=stamp, stopped_by="admin")])
+ prisma.attempt_rows = [{"job_id": "leg-1", "attempt_count": 6}]
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+
+ jobs = await list_shadow_eval_jobs(VIEWER, api_key_id=None, limit=50)
+ assert jobs[0].status == "stopped"
+ assert jobs[0].stopped_by == "admin"
+
+ detail = await get_shadow_eval_job("job-1", VIEWER)
+ assert detail.status == "stopped"
+
+
+@pytest.mark.asyncio
+async def test_backfilled_legacy_stop_never_reads_as_completion(monkeypatch: pytest.MonkeyPatch):
+ """Jobs stopped before stopped_by existed are backfilled with 'unknown' by the
+ migration, so even one whose stray attempts crossed the budget stays stopped."""
+ import litellm.proxy.proxy_server as proxy_server
+
+ prisma = _shadow_prisma(
+ legs=[_leg_record(max_turns=5, stopped_at=datetime.now(timezone.utc), stopped_by="unknown")]
+ )
+ prisma.attempt_rows = [{"job_id": "leg-1", "attempt_count": 6}]
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+
+ jobs = await list_shadow_eval_jobs(VIEWER, api_key_id=None, limit=50)
+ assert jobs[0].status == "stopped"
+
+
+def test_stopped_by_migration_backfills_every_job_that_displayed_stopped():
+ """The migration must close the pre-column population: without the backfill, a
+ legacy stop whose stray attempts crossed the budget would read completed."""
+ import litellm_proxy_extras
+
+ sql = (
+ Path(litellm_proxy_extras.__file__).parent
+ / "migrations"
+ / "20260818224500_add_shadow_eval_stopped_by"
+ / "migration.sql"
+ ).read_text()
+ assert 'ADD COLUMN "stopped_by" TEXT' in sql
+ assert "SET stopped_by = 'unknown'" in sql
+ assert "WHERE stopped_at IS NOT NULL AND ends_at > (NOW() AT TIME ZONE 'utc')" in sql
+
+
+@pytest.mark.asyncio
+async def test_stop_rejects_a_job_that_already_spent_its_budget(monkeypatch: pytest.MonkeyPatch):
+ import litellm.proxy.proxy_server as proxy_server
+
+ prisma = _shadow_prisma(legs=[_leg_record(max_turns=3)])
+ prisma.attempt_rows = [{"job_id": "leg-1", "attempt_count": 3}]
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+
+ with pytest.raises(HTTPException) as exhausted:
+ await stop_shadow_eval_job("job-1", ADMIN)
+ assert exhausted.value.status_code == 400
+ assert "completed" in exhausted.value.detail
+ prisma.db.litellm_shadowevaljob.update_many.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_shadow_eval_responses_name_every_shadowed_key(monkeypatch: pytest.MonkeyPatch):
+ import litellm.proxy.proxy_server as proxy_server
+
+ prisma = _shadow_prisma(
+ legs=[_leg_record(), _leg_record(id="leg-2", api_key_id="deleted-key-hash")],
+ known_keys=("key-hash", "key-hash-2"),
)
monkeypatch.setattr(proxy_server, "llm_router", _shadow_router())
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
- started = await start_shadow_eval(_start_request(), ADMIN)
- assert (started.key_alias, started.key_name) == ("prod-alpha", "sk-...lpha")
-
jobs = await list_shadow_eval_jobs(VIEWER, api_key_id=None, limit=50)
- assert [(job.key_alias, job.key_name) for job in jobs] == [("prod-alpha", "sk-...lpha"), (None, None)]
+ assert [(key.key_alias, key.key_name) for key in jobs[0].keys] == [
+ (None, None),
+ ("prod-alpha", "sk-...lpha"),
+ ]
batched_where = prisma.db.litellm_verificationtoken.find_many.call_args.kwargs["where"]
assert batched_where == {"token": {"in": ["deleted-key-hash", "key-hash"]}}
- prisma.db.litellm_shadowevaljob.find_unique = AsyncMock(return_value=_job_record())
detail = await get_shadow_eval_job("job-1", VIEWER)
- assert detail.key_alias == "prod-alpha"
+ assert [key.key_alias for key in detail.keys] == [None, "prod-alpha"]
@pytest.mark.asyncio
-async def test_stop_shadow_eval_sets_stopped_at_and_rejects_non_running(monkeypatch: pytest.MonkeyPatch):
+async def test_stop_shadow_eval_stops_every_unstopped_leg_and_rejects_non_running(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ """One stop ends sampling for the whole job, while a leg that already stopped on its
+ own budget keeps the stopped_at it earned."""
import litellm.proxy.proxy_server as proxy_server
- prisma = _shadow_prisma()
- prisma.db.litellm_shadowevaljob.find_unique = AsyncMock(return_value=_job_record())
+ earned = datetime.now(timezone.utc) - timedelta(hours=1)
+ prisma = _shadow_prisma(legs=[_leg_record(), _leg_record(id="leg-2", api_key_id="key-hash-2", stopped_at=earned)])
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
stopped = await stop_shadow_eval_job("job-1", ADMIN)
- assert stopped.status == "stopped"
- update = prisma.db.litellm_shadowevaljob.update.call_args.kwargs
- assert set(update["data"]) == {"stopped_at"}
- prisma.db.litellm_shadowevaljob.find_unique = AsyncMock(
- return_value=_job_record(ends_at=datetime.now(timezone.utc) - timedelta(days=1))
- )
+ assert stopped.status == "stopped"
+ assert stopped.stopped_by == "admin"
+ stop_sql, stop_group, stop_operator, stop_stamp = prisma.db.execute_raw.call_args.args
+ assert "SET stopped_by = $2, stopped_at = COALESCE(stopped_at, $3::timestamp)" in stop_sql
+ assert "WHERE group_id = $1 AND stopped_by IS NULL" in stop_sql
+ assert "ends_at > (NOW() AT TIME ZONE 'utc')" in stop_sql
+ assert ") < k.max_turns" in stop_sql
+ assert (stop_group, stop_operator) == ("job-1", "admin")
+ assert datetime.fromisoformat(stop_stamp).tzinfo is None
+ assert prisma.db.execute_raw.await_count == 1
+ prisma.db.litellm_shadowevaljob.update_many.assert_not_called()
+ by_key = {key.api_key_id: key.stopped_at for key in stopped.keys}
+ assert by_key["key-hash-2"] == earned
+ assert by_key["key-hash"] is not None and by_key["key-hash"] != earned
+
+ done_leg = _leg_record(ends_at=datetime.now(timezone.utc) - timedelta(days=1))
+ prisma_done = _shadow_prisma(legs=[done_leg])
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma_done)
with pytest.raises(HTTPException) as exc:
await stop_shadow_eval_job("job-1", ADMIN)
assert exc.value.status_code == 400
+ assert "already completed" in exc.value.detail
+ assert done_leg.stopped_by is None
with pytest.raises(HTTPException) as forbidden:
await stop_shadow_eval_job("job-1", VIEWER)
assert forbidden.value.status_code == 403
+
+
+def test_every_shadow_eval_sql_constant_speaks_naive_utc():
+ """The tables store naive UTC wall time (prisma's convention), so SQL-side time must be
+ NOW() AT TIME ZONE 'utc' and python-side params must cast ::timestamp; a bare NOW() or a
+ timestamptz cast writes session-local wall time into the naive column and skews every
+ comparison against prisma-written stamps."""
+ import litellm.proxy.management_endpoints.auto_router_endpoints as module
+
+ sql_constants = {name: value for name, value in vars(module).items() if name.endswith("_SQL")}
+ assert sql_constants
+ for name, sql in sql_constants.items():
+ assert "::timestamptz" not in sql, name
+ for occurrence in sql.split("NOW()")[1:]:
+ assert occurrence.startswith(" AT TIME ZONE 'utc'"), name
+
+
+@pytest.mark.asyncio
+async def test_a_stop_racing_the_last_budgeted_attempt_reports_completed_not_stopped(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ """The statement claims the job only while a leg still samples, so a stop landing in
+ the same instant the budget spends records nothing and the job keeps reading
+ completed; stamping it would misreport a self-ended job as operator-stopped forever."""
+ import litellm.proxy.proxy_server as proxy_server
+
+ prisma = _shadow_prisma(legs=[_leg_record(max_turns=2)])
+ prisma.attempt_rows = [{"job_id": "leg-1", "attempt_count": 2}]
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+
+ with pytest.raises(HTTPException) as exc:
+ await stop_shadow_eval_job("job-1", ADMIN)
+ assert exc.value.status_code == 400
+ assert "already completed" in exc.value.detail
+ assert prisma.db.litellm_shadowevaljob.find_many.await_args.kwargs["where"] == {"group_id": "job-1"}
+
+
+@pytest.mark.asyncio
+async def test_two_racing_stops_produce_exactly_one_winner(monkeypatch: pytest.MonkeyPatch):
+ """The statement's stopped_by IS NULL predicate lets only one racer claim rows; the
+ loser reads the stamped state and gets the same answer a late caller gets."""
+ import litellm.proxy.proxy_server as proxy_server
+
+ prisma = _shadow_prisma(legs=[_leg_record()])
+ monkeypatch.setattr(proxy_server, "prisma_client", prisma)
+
+ first = await stop_shadow_eval_job("job-1", ADMIN)
+ assert first.status == "stopped"
+
+ with pytest.raises(HTTPException) as exc:
+ await stop_shadow_eval_job("job-1", ADMIN)
+ assert exc.value.status_code == 400
+ assert "already stopped" in exc.value.detail
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx
index 7bb550f729e..b307e3d0f2a 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx
@@ -77,7 +77,15 @@ const job = (overrides: Partial = {}): ShadowEvalJob => ({
baseline_model: null,
judge_model: "anthropic/claude-sonnet-5",
shadow_percentage: 10,
- max_turns: 200,
+ keys: [
+ {
+ api_key_id: "hashed-key-abc",
+ max_turns: 200,
+ stopped_at: null,
+ key_alias: "prod-alpha",
+ key_name: "sk-...alpha",
+ },
+ ],
judged_count: 42,
error_count: 1,
judge_spend: 3.21,
@@ -110,19 +118,28 @@ const job = (overrides: Partial = {}): ShadowEvalJob => ({
avg_judge_confidence: 0.8,
},
],
+ by_key: [],
overall_shadow_win_rate_pct: 48.0,
overall_tie_rate_pct: 22.0,
},
created_at: "2026-08-07T00:00:00Z",
ends_at: "2026-09-07T00:00:00Z",
- stopped_at: null,
- api_key_id: "hashed-key-abc",
- key_alias: "prod-alpha",
- key_name: "sk-...alpha",
last_error: null,
...overrides,
});
+const keyEntry = (
+ api_key_id: string,
+ overrides: Partial = {},
+): ShadowEvalJob["keys"][number] => ({
+ api_key_id,
+ max_turns: 200,
+ stopped_at: null,
+ key_alias: null,
+ key_name: null,
+ ...overrides,
+});
+
const mockHooks = ({
jobs = [],
detailsById = {},
@@ -199,8 +216,8 @@ describe("ShadowEvalSection", () => {
it("gives every active job its own card with a stop button, with the form still offered", () => {
mockHooks({
jobs: [
- job({ job_id: "job-a", status: "running", api_key_id: "key-a" }),
- job({ job_id: "job-b", status: "running", api_key_id: "key-b" }),
+ job({ job_id: "job-a", status: "running", keys: [keyEntry("key-a")] }),
+ job({ job_id: "job-b", status: "running", keys: [keyEntry("key-b")] }),
],
});
render();
@@ -342,7 +359,7 @@ describe("ShadowEvalSection", () => {
expect(container).toBeEmptyDOMElement();
});
- it("keeps the start button disabled until key, router, and judge model are picked, then submits them", async () => {
+ it("keeps the start button disabled until key, router, and judge model are picked, then submits the key as a list", async () => {
const user = userEvent.setup();
const { start } = mockHooks({});
render();
@@ -361,7 +378,7 @@ describe("ShadowEvalSection", () => {
await user.click(screen.getByText("Start shadow eval"));
const expectedBody = {
- api_key_id: "hash-alpha",
+ api_key_ids: ["hash-alpha"],
router_name: "gpt-auto",
direction: "forward",
shadow_percentage: 10,
@@ -396,7 +413,7 @@ describe("ShadowEvalSection", () => {
await user.click(screen.getByText("Start shadow eval"));
const expectedBody = {
- api_key_id: "hash-alpha",
+ api_key_ids: ["hash-alpha"],
router_name: "gpt-auto",
direction: "reverse",
baseline_model: "prod-claude",
@@ -429,9 +446,9 @@ describe("ShadowEvalSection", () => {
});
it("labels the shadowed key by alias, then masked name, then truncated hash", () => {
- expect(shadowedKeyLabel(job())).toBe("prod-alpha");
- expect(shadowedKeyLabel(job({ key_alias: null }))).toBe("sk-...alpha");
- expect(shadowedKeyLabel(job({ key_alias: null, key_name: null }))).toBe("hashed-key…");
+ expect(shadowedKeyLabel(job().keys[0])).toBe("prod-alpha");
+ expect(shadowedKeyLabel(keyEntry("hashed-key-abc", { key_name: "sk-...alpha" }))).toBe("sk-...alpha");
+ expect(shadowedKeyLabel(keyEntry("hashed-key-abc"))).toBe("hashed-key…");
});
it("keeps an older job's verdicts reachable through the previous evaluations list", async () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx
index 005636615b1..6d240a84c98 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx
@@ -24,6 +24,7 @@ import {
useStartShadowEval,
useStopShadowEval,
type ShadowEvalJob,
+ type ShadowEvalJobKey,
type ShadowEvalSlice,
} from "./useShadowEval";
@@ -50,19 +51,24 @@ const routerMatchedOrBeatPct = (
? 100 - results.overall_shadow_win_rate_pct
: results.overall_shadow_win_rate_pct + results.overall_tie_rate_pct;
-export const shadowedKeyLabel = (job: ShadowEvalJob): string =>
- job.key_alias || job.key_name || `${job.api_key_id.slice(0, 10)}…`;
+export const shadowedKeyLabel = (key: ShadowEvalJobKey): string =>
+ key.key_alias || key.key_name || `${key.api_key_id.slice(0, 10)}…`;
+
+const shadowedKeysLabel = (job: ShadowEvalJob): string =>
+ job.keys.length === 1 ? shadowedKeyLabel(job.keys[0]) : `${job.keys.length} keys`;
+
+const totalBudget = (job: ShadowEvalJob): number => job.keys.reduce((sum, key) => sum + key.max_turns, 0);
const jobHeadline = (job: ShadowEvalJob): React.ReactNode =>
job.direction === "reverse" ? (
<>
Comparing {job.router_name} to{" "}
{job.baseline_model} on {job.shadow_percentage}% of{" "}
- {shadowedKeyLabel(job)} traffic
+ {shadowedKeysLabel(job)} traffic
>
) : (
<>
- Shadowing {job.shadow_percentage}% of {shadowedKeyLabel(job)} traffic
+ Shadowing {job.shadow_percentage}% of {shadowedKeysLabel(job)} traffic
via {job.router_name}
>
);
@@ -178,7 +184,8 @@ const emptyResultsText = (job: ShadowEvalJob, resultsError: boolean): string =>
const ResultsBody: React.FC<{ job: ShadowEvalJob; resultsError?: boolean }> = ({ job, resultsError = false }) => {
const results = job.results;
- if (!results || (results.by_tier.length === 0 && results.by_current_model.length === 0)) {
+ const stratifications = results ? [results.by_tier, results.by_current_model, results.by_key] : [];
+ if (!results || stratifications.every((slices) => slices.length === 0)) {
return
@@ -386,14 +393,13 @@ const StartForm: React.FC = () => {
const percentageValid = parsedPct >= 0.1 && parsedPct <= 100;
const parsedMaxTurns = Number.parseInt(maxTurns, 10);
const maxTurnsValid = parsedMaxTurns >= 1 && parsedMaxTurns <= 2000;
- const filled =
- [apiKeyId, routerName, judgeModel].every((field) => field !== "") &&
- (direction === "forward" || baselineModel !== "");
+ const baselinePicked = direction === "forward" || baselineModel !== "";
+ const filled = [apiKeyId, routerName, judgeModel].every((field) => field !== "") && baselinePicked;
const boundsValid = percentageValid && maxTurnsValid;
const valid = Boolean(accessToken) && filled && boundsValid;
const handleStart = () => {
const startBody = {
- api_key_id: apiKeyId,
+ api_key_ids: [apiKeyId],
router_name: routerName,
direction,
...(direction === "reverse" ? { baseline_model: baselineModel } : {}),
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useShadowEval.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useShadowEval.ts
index 7645fcc3346..eef98320e67 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useShadowEval.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useShadowEval.ts
@@ -7,6 +7,7 @@ import { $api, fetchClient } from "@/lib/http/api";
import type { components } from "@/lib/http/schema";
export type ShadowEvalJob = components["schemas"]["ShadowEvalJobResponse"];
+export type ShadowEvalJobKey = components["schemas"]["ShadowEvalJobKeyResponse"];
export type ShadowEvalSlice = components["schemas"]["ShadowEvalSlice"];
export type StartShadowEvalRequest = components["schemas"]["StartShadowEvalRequest"];
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 5794c60e97b..4c63586d4d6 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -816,7 +816,8 @@ export interface paths {
};
/**
* List Shadow Eval Jobs
- * @description List shadow eval jobs, newest first. Counts and results ride the detail endpoint only.
+ * @description 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.
*/
get: operations["list_shadow_eval_jobs_auto_router_shadow_eval_get"];
put?: never;
@@ -838,20 +839,21 @@ export interface paths {
put?: never;
/**
* Start Shadow Eval
- * @description 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.
+ * @description 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.
*/
post: operations["start_shadow_eval_auto_router_shadow_eval_start_post"];
delete?: never;
@@ -891,7 +893,12 @@ export interface paths {
put?: never;
/**
* Stop Shadow Eval Job
- * @description Stop an active shadow eval job. Attempts are kept; sampling halts within ~10s.
+ * @description 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.
*/
post: operations["stop_shadow_eval_job_auto_router_shadow_eval__job_id__stop_post"];
delete?: never;
@@ -33201,18 +33208,49 @@ export interface components {
timeout?: number | null;
};
/**
- * ShadowEvalJobResponse
- * @description 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.
+ * ShadowEvalJobKeyResponse
+ * @description One key a job shadows, with its own budget and stop state.
*/
- ShadowEvalJobResponse: {
+ ShadowEvalJobKeyResponse: {
/**
* Api Key Id
- * @description The hashed virtual key whose traffic this job evaluates, and only that key's
+ * @description The hashed virtual key whose traffic this entry scopes
*/
api_key_id: string;
+ /**
+ * Attempt Count
+ * @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
+ */
+ attempt_count?: number | null;
+ /**
+ * Key Alias
+ * @description Alias of the shadowed key, resolved from the key row at read time; None when unset or deleted
+ */
+ key_alias?: string | null;
+ /**
+ * Key Name
+ * @description Masked display name (sk-...) of the shadowed key, resolved at read time like key_alias
+ */
+ key_name?: string | null;
+ /**
+ * Max Turns
+ * @description This key's own sample budget, independent of its siblings'
+ */
+ max_turns: number;
+ /**
+ * Stopped At
+ * @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
+ */
+ stopped_at?: string | null;
+ };
+ /**
+ * ShadowEvalJobResponse
+ * @description 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.
+ */
+ ShadowEvalJobResponse: {
/** Baseline Model */
baseline_model?: string | null;
/**
@@ -33251,22 +33289,15 @@ export interface components {
*/
judged_count?: number | null;
/**
- * Key Alias
- * @description Alias of the shadowed key, resolved from the key row at read time; None when unset or deleted
+ * Keys
+ * @description The keys whose traffic this job evaluates, and only those keys', each with its own budget
*/
- key_alias?: string | null;
- /**
- * Key Name
- * @description Masked display name (sk-...) of the shadowed key, resolved at read time like key_alias
- */
- key_name?: string | null;
+ keys: components["schemas"]["ShadowEvalJobKeyResponse"][];
/**
* Last Error
* @description Most recent attempt error; detail endpoint only
*/
last_error?: string | null;
- /** Max Turns */
- max_turns: number;
/** @description Stratified verdicts; detail endpoint only */
results?: components["schemas"]["ShadowEvalResult"] | null;
/** Router Name */
@@ -33275,13 +33306,19 @@ export interface components {
shadow_percentage: number;
/**
* Status
- * @description A job whose window has passed reads completed even if a later sweep stamped
- * stopped_at; stopped means sampling ended before the window did.
+ * @description 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.
* @enum {string}
*/
readonly status: "running" | "completed" | "stopped";
- /** Stopped At */
- stopped_at?: string | null;
+ /**
+ * Stopped By
+ * @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
+ */
+ stopped_by?: string | null;
};
/**
* ShadowEvalResult
@@ -33290,9 +33327,14 @@ export interface components {
ShadowEvalResult: {
/**
* By Current Model
- * @description Sliced by the model that served the real arm: the key's incumbent models in forward mode, and in reverse the models the router itself picked
+ * @description 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_current_model: components["schemas"]["ShadowEvalSlice"][];
+ /**
+ * By Key
+ * @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
+ */
+ by_key: components["schemas"]["ShadowEvalSlice"][];
/** By Tier */
by_tier: components["schemas"]["ShadowEvalSlice"][];
/** Overall Shadow Win Rate Pct */
@@ -33500,14 +33542,14 @@ export interface components {
};
/**
* StartShadowEvalRequest
- * @description Start duplicating a key's traffic for blind comparison against an auto-router.
+ * @description Start duplicating one or more keys' traffic for blind comparison against an auto-router.
*/
StartShadowEvalRequest: {
/**
- * Api Key Id
- * @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.
+ * Api Key Ids
+ * @description 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.
*/
- api_key_id: string;
+ api_key_ids: string[];
/**
* Baseline Model
* @description Required when direction is reverse and rejected otherwise: the fixed model the router's own responses are judged against. Must be a plain model rather than another auto-router
@@ -33534,7 +33576,7 @@ export interface components {
judge_model: string;
/**
* Max Turns
- * @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
+ * @description 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
* @default 200
*/
max_turns: number;
@@ -37931,7 +37973,7 @@ export interface operations {
list_shadow_eval_jobs_auto_router_shadow_eval_get: {
parameters: {
query?: {
- /** @description Filter to jobs shadowing this key */
+ /** @description Filter to jobs that shadow this key, alone or alongside others */
api_key_id?: string | null;
/** @description Newest jobs to return */
limit?: number;
From 3d51eb378a027b17e4f123b3eb768b31150bcccb Mon Sep 17 00:00:00 2001
From: yuneng-jiang
Date: Wed, 19 Aug 2026 14:07:50 -0700
Subject: [PATCH 29/88] refactor(ui): migrate the antd Alert call sites onto
the shared Alert (#37513)
Moves all 33 antd Alert usages across 20 dashboard files onto
src/components/shared/Alert, following the composition the rest of the
dashboard already uses: message becomes AlertTitle, description becomes
AlertDescription, showIcon becomes a lucide icon child, and closable
becomes an AlertAction ghost button.
antd type="success" has no counterpart on the shared Alert, so the two
success sites land on the default variant with a CircleCheck icon, which
is what cloudzero_export_modal and CloudZeroIntegrationSettings already
do for the same case.
LoginPage's dismissible SSO notice moves into its own SsoEnabledNotice
component in the same file: antd's closable carried its own dismiss
state, and inlining it pushed LoginPageContent past the complexity
budget.
Four files lose their last antd symbol, so their no-restricted-imports
suppressions are pruned by hand. antd import sites drop from 115 to 111
across 107 to 103 files, and the no-restricted-imports ratchet drops
from 119 to 115 over 110 to 106 files.
One test asserted antd's own ant-alert-info class; it is repointed to
the shared Alert's text-info variant class, which keeps the same
"info, not warning" check. Every other colocated test passes untouched.
---
ui/litellm-dashboard/eslint-suppressions.json | 18 --
.../admin-panel/_components/AdminPanel.tsx | 18 +-
.../_components/MCPPermissionManagement.tsx | 20 +-
.../_components/UserEnvVarsModal.tsx | 14 +-
.../mcp-servers/_components/mcp_connect.tsx | 50 ++--
.../_components/mcp_server_edit.tsx | 20 +-
.../policies/_components/add_policy_form.tsx | 60 ++---
.../_components/policy_test_panel.tsx | 10 +-
.../_components/CreateVectorStore.tsx | 38 +--
.../_components/S3VectorsConfig.tsx | 52 ++--
.../_components/VectorStoreForm.tsx | 246 +++++++++---------
.../src/app/login/LoginPage.tsx | 119 +++++----
.../src/app/onboarding/OnboardingFormBody.tsx | 24 +-
.../src/components/CreateUserButton.tsx | 29 +--
.../MCPSemanticFilterSettings.tsx | 59 +++--
.../src/components/add_model/AddModelForm.tsx | 18 +-
.../src/components/add_pass_through.tsx | 20 +-
.../PassThroughGuardrailsSection.tsx | 40 ++-
.../user_search_modal.test.tsx | 2 +-
.../common_components/user_search_modal.tsx | 18 +-
.../organisms/RegenerateKeyModal.tsx | 10 +-
.../update_model_credentials_modal.tsx | 18 +-
22 files changed, 460 insertions(+), 443 deletions(-)
diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json
index 98225de5d8b..09e602e269b 100644
--- a/ui/litellm-dashboard/eslint-suppressions.json
+++ b/ui/litellm-dashboard/eslint-suppressions.json
@@ -1404,11 +1404,6 @@
"count": 2
}
},
- "src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": {
"no-nested-ternary": {
"count": 2
@@ -1453,9 +1448,6 @@
"local/no-complex-jsx-arrow": {
"count": 1
},
- "no-restricted-imports": {
- "count": 1
- },
"react-hooks/set-state-in-effect": {
"count": 2
}
@@ -1470,11 +1462,6 @@
"count": 1
}
},
- "src/app/onboarding/OnboardingFormBody.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"src/components/AIHub/ModelHubTable.test.tsx": {
"max-params": {
"count": 1
@@ -1998,11 +1985,6 @@
"count": 1
}
},
- "src/components/common_components/PassThroughGuardrailsSection.tsx": {
- "no-restricted-imports": {
- "count": 1
- }
- },
"src/components/common_components/RateLimitTypeFormItem.test.tsx": {
"no-restricted-imports": {
"count": 1
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx
index 48eaa62e37b..e9d7190abf5 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/admin-panel/_components/AdminPanel.tsx
@@ -7,8 +7,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
-import { Alert as AntdAlert, Modal, Space, Tabs, Typography } from "antd";
-import { Info } from "lucide-react";
+import { Modal, Space, Tabs, Typography } from "antd";
+import { Info, TriangleAlert } from "lucide-react";
import React, { useEffect, useState } from "react";
import NewBadge from "@/components/common_components/NewBadge";
import { useBaseUrl } from "@/components/constants";
@@ -223,12 +223,14 @@ const AdminPanel: React.FC = ({ proxySettings }) => {
<>
✨ Security Settings
-
+
+
+ SSO Configuration Deprecated
+
+ Editing SSO Settings on this page is deprecated and will be removed in a future version. Please use the
+ SSO Settings tab for SSO configuration.
+
+
= ({
)}
{showInternalDelegatePkceWarning && (
-
+
+
+ Internal server with upstream OAuth delegation
+
+ This MCP server is configured as internal-only but delegates auth to upstream. Anonymous users will be
+ able to reach the upstream OAuth2 /authorize flow without a LiteLLM session. Ensure your upstream
+ provider and network enforce access controls.
+
+
)}
= ({ server, open, acces
) : isError ? (
-
+
+
+ Failed to load env vars
+
) : required.length === 0 ? (
-
+
+
+ No per-user fields configured for this server.
+
) : (
<>
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx
index bb739901faa..8aeb94404f2 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx
@@ -1,10 +1,22 @@
/* eslint-disable react/no-unescaped-entities */
import React, { useState } from "react";
-import { Card, Typography, Space, Alert, Switch } from "antd";
+import { Card, Typography, Space, Switch } from "antd";
+import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { CopyIcon, Code, Terminal, Globe, CheckIcon, ExternalLinkIcon, KeyIcon, ServerIcon, Zap } from "lucide-react";
+import {
+ CopyIcon,
+ Code,
+ Terminal,
+ Globe,
+ CheckIcon,
+ ExternalLinkIcon,
+ Info,
+ KeyIcon,
+ ServerIcon,
+ Zap,
+} from "lucide-react";
import { getProxyBaseUrl } from "@/components/networking";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
@@ -69,25 +81,21 @@ const FeatureCard: React.FC = ({
{useServerHeader && (
-
-
- Option 1: Get a specific server: "{serverName.replace(/\s+/g, "_")}"
-
-
- Option 2: Get a group of MCPs: "dev-group"
-
-
- You can also mix both: "Server1,dev-group"
-
-
- }
- />
+
+
+ Two Options
+
+
+ Option 1: Get a specific server: "{serverName.replace(/\s+/g, "_")}"
+
+
+ Option 2: Get a group of MCPs: "dev-group"
+
+
+ You can also mix both: "Server1,dev-group"
+
+
+
)}
)}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx
index eb462f788a5..94b66f1bdfa 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from "react";
-import { Select, Tooltip, Input, InputNumber, Alert } from "antd";
+import { Select, Tooltip, Input, InputNumber } from "antd";
+import { TriangleAlert } from "lucide-react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { FormProvider, useForm } from "react-hook-form";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button } from "@/components/ui/button";
@@ -1041,13 +1043,15 @@ const MCPServerEdit: React.FC = ({
{!isStdioTransport && isOAuthAuthType && (
<>
{!oauthFlowTypeValue && !isDelegateAuth && (
-
+
+
+ This server has no OAuth flow set
+
+ Choose Machine-to-Machine (M2M) or Interactive (PKCE) so LiteLLM authenticates it the way you
+ intend, then save. Until it is set, LiteLLM falls back to interactive per-user auth and treats
+ a machine-to-machine credential shape conservatively.
+
+
)}
= ({
{selectedMode === "flow_builder" && (
+ >
+
+ You'll be redirected to the full-screen Flow Builder to design your policy logic visually.
+
+
)}
@@ -457,35 +461,33 @@ const AddPolicyForm: React.FC = ({
{resolvedGuardrails.length > 0 && (
-
-
- These are the final guardrails that will be applied (including inheritance):
-
-
- }
- type="info"
- showIcon
- />
+
+
)}
-
+
+
+ Model Scope
+
+ By default, this policy will run on all models. You can optionally restrict it to specific models below.
+
+
Model Condition Type
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx
index 90642d23a46..9c234a7a70c 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/policy_test_panel.tsx
@@ -1,6 +1,8 @@
import React, { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
-import { Alert, Empty } from "antd";
+import { Empty } from "antd";
+import { CircleAlert } from "lucide-react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { resolvePoliciesCall, teamListCall, keyListCall, modelAvailableCall } from "@/components/networking";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { FieldGroup } from "@/components/shared/form/field";
@@ -325,7 +327,11 @@ const PolicyTestPanel: React.FC = ({ accessToken }) => {
)}
{hasSearched && !result && !isLoading && (
-
+
+
+ Error
+ Failed to resolve policies. Check the proxy logs.
+
)}
);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx
index 93aaa90ef7c..271ef31a68e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/CreateVectorStore.tsx
@@ -1,9 +1,10 @@
import React, { useState } from "react";
-import { Upload, Alert } from "antd";
+import { Upload } from "antd";
import { toast } from "@/lib/toast";
import { InboxOutlined } from "@ant-design/icons";
import type { UploadProps } from "antd";
-import { CircleHelp } from "lucide-react";
+import { CircleCheck, CircleHelp, X } from "lucide-react";
+import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { ragIngestCall } from "@/components/networking";
import { DocumentUpload, RAGIngestResponse } from "@/components/vector_store_management/types";
import DocumentsTable from "./DocumentsTable";
@@ -359,22 +360,23 @@ const CreateVectorStore: React.FC = ({ accessToken, onSu
{/* Success Message */}
{ingestResults.length > 0 && (
-
-
- Vector Store ID: {ingestResults[0]?.vector_store_id}
-
-
- Documents Ingested: {ingestResults.length}
-
-
- }
- type="success"
- showIcon
- closable
- />
+
+
+ Vector Store Created Successfully
+
+
+ Vector Store ID: {ingestResults[0]?.vector_store_id}
+
+
+ Documents Ingested: {ingestResults.length}
+
+
+
+
+
+
)}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx
index 3e286ee9b92..cdc63e8b7ec 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/S3VectorsConfig.tsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
-import { Alert } from "antd";
-import { CircleHelp } from "lucide-react";
+import { CircleHelp, Info } from "lucide-react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import { Field, FieldError, FieldLabel } from "@/components/shared/form/field";
import {
@@ -72,32 +72,28 @@ const S3VectorsConfig: React.FC = ({ accessToken, provider
return (
-
-
AWS S3 Vectors allows you to store and query vector embeddings directly in S3:
-
-
Vector buckets and indexes will be automatically created if they don't exist
-
Vector dimensions are auto-detected from your selected embedding model
-
Ensure your AWS credentials have permissions for S3 Vectors operations
Configure your PostgreSQL database with pgvector extension
+
Start the server and note the API base URL and API key
+
Enter those details in the fields below
+
+
+
)}
{selectedProvider === "valkey" && (
-
-
- 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:
-
-
-
- Your Valkey server has vector search enabled (the valkey-search module, included in the
- valkey-bundle image and in AWS ElastiCache / MemoryDB for Valkey)
-
-
- You have already created a search index and loaded your documents and their embeddings into it.
- Enter that index name as the Vector Store ID
-
-
- You know which embedding model created those stored embeddings. That model must be added to this
- proxy under Models so you can pick it below. Using a different model returns wrong results
-
-
- You know the field names your documents use for their text and their embedding. If they are not
- "text" and "embedding", set them below
-
-
-
- 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:
+
+
+
+ Your Valkey server has vector search enabled (the valkey-search module, included in the
+ valkey-bundle image and in AWS ElastiCache / MemoryDB for Valkey)
+
+
+ You have already created a search index and loaded your documents and their embeddings into it.
+ Enter that index name as the Vector Store ID
+
+
+ You know which embedding model created those stored embeddings. That model must be added to this
+ proxy under Models so you can pick it below. Using a different model returns wrong results
+
+
+ You know the field names your documents use for their text and their embedding. If they are not
+ "text" and "embedding", set them below
+
+
+
+ When a query comes in, LiteLLM converts it to an embedding with the model below and returns the
+ closest matching documents from your index.
+
- For most data store types (Cloud Storage, BigQuery, Media): copy the data store ID and enter it
- in the Vector Store ID field below.
-
-
- For website, healthcare, and connector-based sources (Drive, Gmail, Slack, Jira, etc.): create a
- search app on top of the data store, then copy the Engine ID and enter it in
- the Engine ID field. The Vector Store ID is still required as the LiteLLM-side name for this
- record, but it isn't used in the GCP URL when Engine ID is set.
-
+ For most data store types (Cloud Storage, BigQuery, Media): copy the data store ID and enter it in
+ the Vector Store ID field below.
+
+
+ For website, healthcare, and connector-based sources (Drive, Gmail, Slack, Jira, etc.): create a
+ search app on top of the data store, then copy the Engine ID and enter it in the
+ Engine ID field. The Vector Store ID is still required as the LiteLLM-side name for this record,
+ but it isn't used in the GCP URL when Engine ID is set.
+
+
+
+
)}
;
+function SsoEnabledNotice() {
+ const [isDismissed, setIsDismissed] = useState(false);
+ if (isDismissed) return null;
+
+ return (
+
+
+
+ Single Sign-On (SSO) is enabled. LiteLLM no longer automatically redirects to the SSO login flow upon loading
+ this page. To re-enable auto-redirect-to-SSO, set{" "}
+ AUTO_REDIRECT_UI_LOGIN_TO_SSO=true in your
+ environment configuration.
+
+
+
+
+
+ );
+}
+
function LoginPageContent() {
const [isLoading, setIsLoading] = useState(true);
const { data: uiConfig, isLoading: isConfigLoading } = useUIConfig();
@@ -170,22 +192,19 @@ function LoginPageContent() {
🚅 LiteLLM
-
-
- The Admin UI has been disabled by the administrator. To re-enable it, please update the following
- environment variable:
-