feat(spend): capture-rate check of LiteLLM spend against the OpenAI bill (#43044)

* feat(spend): capture-rate check of LiteLLM spend against the OpenAI bill

* fix(spend): claim the alert lock after the check, NaN gauge on no rate, 180-day range cap, live settings, OpenAI adapter under llms

* fix(spend): chart the capture-rate gauge in the all-metrics dashboard and clear it when the check is removed

* fix(prometheus): record the capture-rate gauge when api_provider is an excluded label

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-24 17:09:28 -07:00 • committed by GitHub
parent 27d1974e2f
commit c183d810f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2012 additions and 433 deletions

View file

@ -1697,6 +1697,63 @@
"title": "litellm_video_duration_seconds_metric rate",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"description": "Share of the provider's bill LiteLLM captured as spend over the scheduled capture-rate check's window (needs general_settings.spend_capture_rate_check); NaN while no rate is available",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 1,
"showPoints": "never",
"spanNulls": false
},
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 107
},
"id": 111,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"editorMode": "code",
"expr": "max by (api_provider) (litellm_spend_capture_rate)",
"legendFormat": "{{api_provider}}",
"range": true,
"refId": "A"
}
],
"title": "litellm_spend_capture_rate",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {

View file

@ -1,6 +1,6 @@
# LiteLLM All Prometheus Metrics dashboard
Every `litellm_*` metric family the proxy can expose on `/metrics` (134 families across 95 panels), grouped into rows: proxy traffic, latency, spend and tokens, cache, LLM API deployments, key and team rate limits, budgets, guardrails, MCP, managed files and batches, users and teams, the Redis circuit breaker, the spend log cleanup job, and the `prometheus_system` service callback metrics (per-service latency, request and failure rates, spend update queue sizes). Panel titles are the metric names so you can grep the JSON for the metric you care about
Every `litellm_*` metric family the proxy can expose on `/metrics` (136 families across 97 panels), grouped into rows: proxy traffic, latency, spend and tokens, cache, LLM API deployments, key and team rate limits, budgets, guardrails, MCP, managed files and batches, users and teams, the Redis circuit breaker, the spend log cleanup job, and the `prometheus_system` service callback metrics (per-service latency, request and failure rates, spend update queue sizes). Panel titles are the metric names so you can grep the JSON for the metric you care about
Import `grafana_dashboard.json` from **Dashboards > New > Import** and pick your Prometheus data source when prompted (the `DS_PROMETHEUS` variable). Counters are plotted as `rate()` over `$__rate_interval`, histograms as p50 / p95 / p99, gauges as the raw value grouped by the most useful label. Every query names the metric exactly as the proxy emits it (counters carry the `_total` suffix the Prometheus client adds), and `tests/test_litellm/integrations/test_prometheus_metric_name_consistency.py` fails if a metric is renamed without updating this dashboard

View file

@ -2122,6 +2122,14 @@ PTU_LAPSED_ALERT_LIMIT: Final[int] = 10
DAILY_GLOBAL_SPEND_RECONCILE_JOB_ID: Final[str] = "daily_global_spend_reconcile_job"
DAILY_GLOBAL_SPEND_RECONCILE_LOCK_TTL_SECONDS: Final[int] = 3600
DAILY_GLOBAL_SPEND_RECONCILED_THROUGH_PARAM: Final[str] = "daily_global_spend_reconciled_through"
SPEND_CAPTURE_RATE_CHECK_JOB_ID: Final[str] = "spend_capture_rate_check_job"
SPEND_CAPTURE_RATE_CHECK_LOCK_TTL_SECONDS: Final[int] = 900
SPEND_CAPTURE_RATE_MAX_RANGE_DAYS: Final[int] = 180
SPEND_CAPTURE_RATE_DOCS_URL: Final[str] = "https://docs.litellm.ai/docs/proxy/spend_capture_rate"
OPENAI_ORGANIZATION_COSTS_URL: Final[str] = "https://api.openai.com/v1/organization/costs"
# Buckets per page the OpenAI costs endpoint allows (1 to 180, default 7), 2026-09-24
OPENAI_ORGANIZATION_COSTS_PAGE_LIMIT: Final[int] = 180
PROVIDER_BILLING_TIMEOUT_SECONDS: Final[float] = 30.0
# Slack allowed when deciding a sentinel row is stale. The row's updated_at and the
# run's cutoff are stamped by different hosts, so clock skew between them must not let
# one run delete a charge another just wrote. A stale row is hours old and a concurrent

View file

@ -729,6 +729,15 @@ class PrometheusLogger(CustomLogger):
labelnames=self.get_labels_for_metric("litellm_zero_cost_requests_total"),
)
self.litellm_spend_capture_rate = self._gauge_factory(
"litellm_spend_capture_rate",
(
"Share of the provider's bill LiteLLM captured as spend over the scheduled check's window "
"(captured spend / provider bill), by api_provider; NaN when the last check produced no rate"
),
labelnames=self.get_labels_for_metric("litellm_spend_capture_rate"),
)
# Cache metrics
self.litellm_cache_hits_metric = self._counter_factory(
name="litellm_cache_hits_metric",
@ -2028,6 +2037,15 @@ class PrometheusLogger(CustomLogger):
)
self.litellm_zero_cost_requests_total.labels(**labels).inc()
def set_spend_capture_rate(self, api_provider: str, capture_rate: float | None) -> None:
labels: Final = prometheus_label_factory(
supported_enum_labels=self.get_labels_for_metric("litellm_spend_capture_rate"),
enum_values=UserAPIKeyLabelValues(api_provider=api_provider),
)
gauge: Final = self.litellm_spend_capture_rate
series: Final = gauge.labels(**labels) if labels else gauge
series.set(math.nan if capture_rate is None else capture_rate)
@staticmethod
def _get_remaining_from_v3_rate_limit_headers(
standard_logging_payload: StandardLoggingPayload | None,

View file

@ -0,0 +1,133 @@
"""OpenAI's organization costs endpoint: the USD the organization was billed per UTC day, read with an admin key."""
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from types import MappingProxyType
from typing import Final, Literal, TypeAlias
import httpx
from pydantic import BaseModel, ConfigDict, ValidationError
from litellm.constants import (
OPENAI_ORGANIZATION_COSTS_PAGE_LIMIT,
OPENAI_ORGANIZATION_COSTS_URL,
PROVIDER_BILLING_TIMEOUT_SECONDS,
)
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider
OPENAI_ADMIN_KEY_ENV_VAR: Final = "OPENAI_ADMIN_KEY"
BillingHttpGet: TypeAlias = Callable[
[str, Mapping[str, object], Mapping[str, str]], # mutable-ok: Callable parameter list is type syntax
Awaitable[httpx.Response],
]
@dataclass(frozen=True, slots=True)
class OpenAICostsRequestFailed:
detail: str
class _OpenAICostAmount(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
value: float
currency: Literal["usd"]
class _OpenAICostResult(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
amount: _OpenAICostAmount
class _OpenAICostBucket(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
start_time: int
results: tuple[_OpenAICostResult, ...] = ()
class _OpenAICostsPage(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
data: tuple[_OpenAICostBucket, ...]
has_more: bool = False
next_page: str | None = None
async def provider_billing_get(url: str, params: Mapping[str, object], headers: Mapping[str, str]) -> httpx.Response:
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.ProviderBilling)
return await client.get(
url,
params=dict(params), # mutable-ok: AsyncHTTPHandler.get takes dict params
headers=dict(headers), # mutable-ok: AsyncHTTPHandler.get takes dict headers
timeout=PROVIDER_BILLING_TIMEOUT_SECONDS,
)
def _utc_midnight(day: date) -> int:
return int(datetime(day.year, day.month, day.day, tzinfo=timezone.utc).timestamp())
def _bucket_day(bucket: _OpenAICostBucket) -> str:
return datetime.fromtimestamp(bucket.start_time, tz=timezone.utc).date().isoformat()
async def fetch_openai_daily_costs(
start_date: date,
end_date: date,
*,
admin_key: str,
project_ids: Sequence[str] = (),
http_get: BillingHttpGet = provider_billing_get,
) -> Mapping[str, float] | OpenAICostsRequestFailed:
"""USD billed by OpenAI per UTC day (ISO date) over the closed range, following pagination to the end."""
scope: Final = (("project_ids[]", tuple(project_ids)),) if project_ids else ()
window: Final[Mapping[str, object]] = MappingProxyType(
{
key: value
for key, value in (
("start_time", _utc_midnight(start_date)),
("end_time", _utc_midnight(end_date + timedelta(days=1))),
("bucket_width", "1d"),
("limit", OPENAI_ORGANIZATION_COSTS_PAGE_LIMIT),
*scope,
)
}
)
headers: Final[Mapping[str, str]] = MappingProxyType({"Authorization": f"Bearer {admin_key}"})
async def fetch_from(page: str | None) -> tuple[_OpenAICostBucket, ...] | OpenAICostsRequestFailed:
params: Final[Mapping[str, object]] = MappingProxyType(
{key: value for key, value in (*window.items(), ("page", page)) if value is not None}
)
try:
response: Final = await http_get(OPENAI_ORGANIZATION_COSTS_URL, params, headers)
except httpx.HTTPError as exc:
return OpenAICostsRequestFailed(f"request failed: {exc}")
if response.status_code != 200:
return OpenAICostsRequestFailed(f"HTTP {response.status_code}: {response.text[:300]}")
try:
parsed: Final = _OpenAICostsPage.model_validate(response.json())
except (ValueError, ValidationError) as exc:
return OpenAICostsRequestFailed(f"unexpected response shape: {exc}")
if not parsed.has_more or parsed.next_page is None:
return parsed.data
rest: Final = await fetch_from(parsed.next_page)
return rest if isinstance(rest, OpenAICostsRequestFailed) else parsed.data + rest
buckets: Final = await fetch_from(None)
if isinstance(buckets, OpenAICostsRequestFailed):
return buckets
days: Final = frozenset(_bucket_day(bucket) for bucket in buckets)
return MappingProxyType(
{
day: sum(
result.amount.value for bucket in buckets if _bucket_day(bucket) == day for result in bucket.results
)
for day in days
}
)

View file

@ -51,6 +51,7 @@ from litellm.types.proxy.carried_budget_state import (
UserBudgetSnapshot,
)
from litellm.types.proxy.control_plane_endpoints import WorkerRegistryEntry
from litellm.types.proxy.spend_capture_rate import SpendCaptureRateCheckSettings
from litellm.types.router import RouterErrors, UpdateRouterConfig
from litellm.types.router_weights import validate_router_settings_dict
from litellm.types.secret_managers.main import KeyManagementSystem
@ -779,6 +780,7 @@ class LiteLLMRoutes(enum.Enum):
"/global/spend/provider",
"/global/spend/tags",
"/global/spend/all_tag_names",
"/spend/capture_rate",
]
public_routes = frozenset(
@ -2948,6 +2950,14 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"every replica. On by default; set to tune the window, pin a job, or turn it off."
),
)
spend_capture_rate_check: SpendCaptureRateCheckSettings | None = Field(
None,
description=(
"Daily check of the spend LiteLLM captured against the provider's own bill (OpenAI via OPENAI_ADMIN_KEY). "
"Publishes litellm_spend_capture_rate per provider and alerts when the ratio over the lookback window "
"falls under the threshold (default 0.9). Off unless set."
),
)
maximum_spend_logs_retention_period: str | None = Field(
None,
description="Maximum retention period for spend logs (e.g., '7d' for 7 days). Logs older than this will be deleted.",

View file

@ -293,6 +293,7 @@ from litellm.constants import (
REALTIME_SESSION_FAILURE_LOGGED_KEY,
REALTIME_SESSION_SUCCESS_LOGGED_KEY,
ROUTER_SETTINGS_MANAGED_OUTSIDE_CONFIG,
SPEND_CAPTURE_RATE_CHECK_JOB_ID,
USER_SPEND_ALERTS_JOB_ID,
WEEKLY_SPEND_REPORT_JOB_ID,
)
@ -758,6 +759,9 @@ from litellm.proxy.spend_tracking.budget_reservation import (
from litellm.proxy.spend_tracking.daily_global_spend_rollup import (
run_scheduled_daily_global_spend_reconcile,
)
from litellm.proxy.spend_tracking.spend_capture_rate import (
run_scheduled_spend_capture_rate_check,
)
from litellm.proxy.spend_tracking.spend_counter_batch import (
PendingSpendIncrement,
active_spend_counter_batch,
@ -856,6 +860,7 @@ from litellm.types.proxy.model_deprecation import (
DEFAULT_DEPRECATION_WARN_DAYS,
ModelDeprecationResponse,
)
from litellm.types.proxy.spend_capture_rate import SpendCaptureProvider, SpendCaptureRateCheckSettings
from litellm.types.realtime import RealtimeQueryParams
from litellm.types.router import (
ClassifierPlugin,
@ -5060,6 +5065,11 @@ def _bind_general_settings_store(settings: SettingsStore) -> None:
general_settings = settings # pyright: ignore[reportAssignmentType] # legacy global accepts mappings
def _current_general_settings() -> Mapping[str, object]:
"""The live ``general_settings``, whichever object a config reload has bound since the caller was created."""
return general_settings
@lru_cache(maxsize=4096)
def _log_ignored_cost_map_copy(model_id: str, fields: tuple[str, ...]) -> None:
verbose_proxy_logger.warning(
@ -10448,6 +10458,13 @@ class ProxyStartupEvent:
prisma_client=prisma_client,
)
cls._initialize_spend_capture_rate_check_job(
scheduler=scheduler,
proxy_logging_obj=proxy_logging_obj,
prisma_client=prisma_client,
read_general_settings=_current_general_settings,
)
### PTU DAILY ROLLUP ###
from litellm.proxy.spend_tracking.ptu_feature_flag import (
is_ptu_cost_attribution_enabled,
@ -10822,6 +10839,64 @@ class ProxyStartupEvent:
next_run_time=datetime.now(timezone.utc) + timedelta(minutes=2),
)
@classmethod
def _initialize_spend_capture_rate_check_job(
cls,
scheduler: AsyncIOScheduler,
proxy_logging_obj: ProxyLogging,
prisma_client: PrismaClient,
read_general_settings: Callable[[], Mapping[str, object]],
) -> None:
"""The job always runs and re-reads ``spend_capture_rate_check`` each run; an absent setting clears the gauge."""
cls._spend_capture_rate_check_settings(read_general_settings())
async def alert(message: str) -> None:
await proxy_logging_obj.alerting_handler(
message=message,
level="High",
alert_type=AlertType.failed_tracking_spend,
)
def publish(provider: str, capture_rate: float | None) -> None:
from litellm.integrations.prometheus import PrometheusLogger
for logger in litellm.logging_callback_manager.get_custom_loggers_for_type(callback_type=PrometheusLogger):
if isinstance(logger, PrometheusLogger):
logger.set_spend_capture_rate(api_provider=provider, capture_rate=capture_rate)
async def check() -> None:
settings: Final = cls._spend_capture_rate_check_settings(read_general_settings())
if settings is None:
for provider in get_args(SpendCaptureProvider):
publish(provider, None)
return
await run_scheduled_spend_capture_rate_check(
prisma_client,
settings,
pod_lock_manager=proxy_logging_obj.db_spend_update_writer.pod_lock_manager,
alert=alert,
publish=publish,
)
scheduler.add_job(
check,
"cron",
hour=1,
minute=15,
timezone="UTC",
id=SPEND_CAPTURE_RATE_CHECK_JOB_ID,
replace_existing=True,
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
next_run_time=datetime.now(timezone.utc) + timedelta(minutes=2),
)
@staticmethod
def _spend_capture_rate_check_settings(
general_settings: Mapping[str, object],
) -> SpendCaptureRateCheckSettings | None:
raw_settings: Final = general_settings.get("spend_capture_rate_check")
return None if raw_settings is None else SpendCaptureRateCheckSettings.model_validate(raw_settings)
@classmethod
async def _initialize_slack_alerting_jobs(
cls,

View file

@ -0,0 +1,300 @@
"""Compare the spend LiteLLM captured for a provider against what that provider billed for the same UTC days.
LiteLLM's side is ``LiteLLM_DailyUserSpend``, summed over the ``custom_llm_provider`` values that land on the
provider's bill. The provider's side is its billing API, read with the customer's own billing credential
(OpenAI: the organization costs endpoint and an admin key in ``OPENAI_ADMIN_KEY``).
"""
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, TypeAlias
from pydantic import BaseModel, ConfigDict, TypeAdapter
from typing_extensions import assert_never
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
SPEND_CAPTURE_RATE_CHECK_JOB_ID,
SPEND_CAPTURE_RATE_CHECK_LOCK_TTL_SECONDS,
SPEND_CAPTURE_RATE_DOCS_URL,
)
from litellm.llms.openai.organization_costs import (
OPENAI_ADMIN_KEY_ENV_VAR,
BillingHttpGet,
OpenAICostsRequestFailed,
fetch_openai_daily_costs,
provider_billing_get,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.proxy.spend_capture_rate import (
CaptureRateDay,
CaptureRateReport,
SpendCaptureProvider,
SpendCaptureRateCheckSettings,
)
if TYPE_CHECKING:
from litellm.caching.redis_cache import RedisCache
from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager
from litellm.proxy.utils import PrismaClient
OPENAI_BILLED_LITELLM_PROVIDERS: Final = ("openai", "text-completion-openai")
CaptureRatePublisher: TypeAlias = Callable[[SpendCaptureProvider, float | None], None] # mutable-ok: Callable params
_CAPTURED_SPEND_BY_DAY_SQL: Final = """
SELECT date, COALESCE(SUM(spend), 0)::float AS spend
FROM "LiteLLM_DailyUserSpend"
WHERE date >= $1 AND date <= $2 AND custom_llm_provider = ANY($3::text[])
GROUP BY date
"""
@dataclass(frozen=True, slots=True)
class ProviderBillingCredentialMissing:
provider: SpendCaptureProvider
env_var: str
@dataclass(frozen=True, slots=True)
class ProviderBillingRequestFailed:
provider: SpendCaptureProvider
detail: str
ProviderBillingFailure: TypeAlias = ProviderBillingCredentialMissing | ProviderBillingRequestFailed
CheckResult: TypeAlias = CaptureRateReport | ProviderBillingFailure
class _CapturedSpendRow(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
date: str
spend: float
_CAPTURED_SPEND_ROWS: Final = TypeAdapter(tuple[_CapturedSpendRow, ...])
async def captured_spend_by_day(
prisma_client: "PrismaClient",
*,
litellm_providers: Sequence[str],
start_date: date,
end_date: date,
) -> Mapping[str, float]:
"""LiteLLM's tracked spend per UTC day (ISO date) for the given ``custom_llm_provider`` values."""
rows: Final = await prisma_client.db.query_raw(
_CAPTURED_SPEND_BY_DAY_SQL, start_date.isoformat(), end_date.isoformat(), tuple(litellm_providers)
)
return MappingProxyType({row.date: row.spend for row in _CAPTURED_SPEND_ROWS.validate_python(rows)})
def _ratio(captured: float, billed: float) -> float | None:
return None if billed <= 0 else captured / billed
def _days(start_date: date, end_date: date) -> tuple[date, ...]:
return tuple(start_date + timedelta(days=offset) for offset in range((end_date - start_date).days + 1))
def compute_capture_rate(
*,
provider: SpendCaptureProvider,
start_date: date,
end_date: date,
captured_by_day: Mapping[str, float],
billed_by_day: Mapping[str, float],
threshold: float,
) -> CaptureRateReport:
days: Final = tuple(
CaptureRateDay(
date=day.isoformat(),
captured_spend=captured_by_day.get(day.isoformat(), 0.0),
provider_spend=billed_by_day.get(day.isoformat(), 0.0),
capture_rate=_ratio(captured_by_day.get(day.isoformat(), 0.0), billed_by_day.get(day.isoformat(), 0.0)),
)
for day in _days(start_date, end_date)
)
captured: Final = sum(day.captured_spend for day in days)
billed: Final = sum(day.provider_spend for day in days)
rate: Final = _ratio(captured, billed)
return CaptureRateReport(
provider=provider,
start_date=start_date.isoformat(),
end_date=end_date.isoformat(),
captured_spend=captured,
provider_spend=billed,
capture_rate=rate,
threshold=threshold,
below_threshold=rate is not None and rate < threshold,
days=days,
)
async def capture_rate_report(
prisma_client: "PrismaClient",
*,
provider: SpendCaptureProvider,
start_date: date,
end_date: date,
threshold: float,
openai_project_ids: Sequence[str] = (),
http_get: BillingHttpGet = provider_billing_get,
) -> CheckResult:
match provider:
case "openai":
admin_key: Final = get_secret_str(OPENAI_ADMIN_KEY_ENV_VAR)
if admin_key is None:
return ProviderBillingCredentialMissing(provider, OPENAI_ADMIN_KEY_ENV_VAR)
billed: Final = await fetch_openai_daily_costs(
start_date, end_date, admin_key=admin_key, project_ids=openai_project_ids, http_get=http_get
)
if isinstance(billed, OpenAICostsRequestFailed):
return ProviderBillingRequestFailed(provider, billed.detail)
captured: Final = await captured_spend_by_day(
prisma_client,
litellm_providers=OPENAI_BILLED_LITELLM_PROVIDERS,
start_date=start_date,
end_date=end_date,
)
return compute_capture_rate(
provider=provider,
start_date=start_date,
end_date=end_date,
captured_by_day=captured,
billed_by_day=billed,
threshold=threshold,
)
case _:
assert_never(provider)
def alert_message(result: CheckResult) -> str | None:
"""The alert a check outcome warrants, or ``None`` when the capture rate is healthy."""
match result:
case ProviderBillingCredentialMissing(provider=provider, env_var=env_var):
return (
f"Spend capture-rate check: {env_var} is not set, so the {provider} bill cannot be read. "
f"Set it or remove general_settings.spend_capture_rate_check. {SPEND_CAPTURE_RATE_DOCS_URL}"
)
case ProviderBillingRequestFailed(provider=provider, detail=detail):
return f"Spend capture-rate check: could not read the {provider} bill ({detail}). {SPEND_CAPTURE_RATE_DOCS_URL}"
case CaptureRateReport():
if not result.below_threshold or result.capture_rate is None:
return None
return (
f"Spend capture rate for {result.provider} is {result.capture_rate:.1%}, under the "
f"{result.threshold:.0%} threshold: LiteLLM captured ${result.captured_spend:,.2f} of the "
f"${result.provider_spend:,.2f} {result.provider} bill for {result.start_date} to {result.end_date}. "
f"Requests reach {result.provider} outside LiteLLM or cost tracking is dropping spend. "
f"{SPEND_CAPTURE_RATE_DOCS_URL}"
)
case _:
assert_never(result)
def _published_rate(result: CheckResult) -> float | None:
"""The gauge value: the rate, or ``None`` (NaN on the gauge) when this window produced no rate."""
return result.capture_rate if isinstance(result, CaptureRateReport) else None
async def _check_every_provider(
prisma_client: "PrismaClient",
settings: SpendCaptureRateCheckSettings,
*,
publish: CaptureRatePublisher,
today: date | None,
http_get: BillingHttpGet,
) -> tuple[CheckResult, ...]:
"""Check every configured provider over the closed days before ``today`` and publish each outcome."""
end_date: Final = (today or datetime.now(timezone.utc).date()) - timedelta(days=1)
start_date: Final = end_date - timedelta(days=settings.lookback_days - 1)
results: Final = tuple(
[
await capture_rate_report(
prisma_client,
provider=provider,
start_date=start_date,
end_date=end_date,
threshold=settings.threshold,
openai_project_ids=settings.openai_project_ids,
http_get=http_get,
)
for provider in settings.providers
]
)
for result in results:
publish(result.provider, _published_rate(result))
verbose_proxy_logger.info("Spend capture-rate check: %s", result)
return results
def _alert_messages(results: Sequence[CheckResult]) -> tuple[str, ...]:
return tuple(message for message in map(alert_message, results) if message is not None)
async def run_spend_capture_rate_check(
prisma_client: "PrismaClient",
settings: SpendCaptureRateCheckSettings,
*,
alert: Callable[[str], Awaitable[None]],
publish: CaptureRatePublisher,
today: date | None = None,
http_get: BillingHttpGet = provider_billing_get,
) -> tuple[CheckResult, ...]:
"""Check every configured provider, publish each rate, and alert on every outcome that warrants one."""
results: Final = await _check_every_provider(
prisma_client, settings, publish=publish, today=today, http_get=http_get
)
for message in _alert_messages(results):
await alert(message)
return results
async def run_scheduled_spend_capture_rate_check(
prisma_client: "PrismaClient",
settings: SpendCaptureRateCheckSettings,
*,
pod_lock_manager: "PodLockManager | None",
alert: Callable[[str], Awaitable[None]],
publish: CaptureRatePublisher,
today: date | None = None,
http_get: BillingHttpGet = provider_billing_get,
) -> tuple[CheckResult, ...]:
"""Every worker publishes its own gauge; the first replica whose finished check has an alert claims the window."""
results: Final = await _check_every_provider(
prisma_client, settings, publish=publish, today=today, http_get=http_get
)
messages: Final = _alert_messages(results)
if not messages:
return results
if not await _claims_alert_window(pod_lock_manager):
verbose_proxy_logger.info("Spend capture-rate check: another pod alerted this window")
return results
for message in messages:
await alert(message)
return results
async def _claims_alert_window(pod_lock_manager: "PodLockManager | None") -> bool:
"""The lock is left to expire, so every replica firing within its TTL of the winner stays quiet."""
redis_cache: Final = None if pod_lock_manager is None else pod_lock_manager.redis_cache
if pod_lock_manager is None or redis_cache is None:
return True
acquired: Final = await pod_lock_manager.acquire_lock(
cronjob_id=SPEND_CAPTURE_RATE_CHECK_JOB_ID, ttl=SPEND_CAPTURE_RATE_CHECK_LOCK_TTL_SECONDS
)
return acquired or not await _lock_is_held(pod_lock_manager, redis_cache)
async def _lock_is_held(pod_lock_manager: "PodLockManager", redis_cache: "RedisCache") -> bool:
try:
return bool(
await redis_cache.async_get_cache(pod_lock_manager.get_redis_lock_key(SPEND_CAPTURE_RATE_CHECK_JOB_ID))
)
except Exception as exc: # noqa: BLE001 # an unreadable lock must not silence the alert
verbose_proxy_logger.warning("Spend capture-rate check: could not read the lock: %s", exc)
return False

View file

@ -24,7 +24,7 @@ from typing import (
import fastapi
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import TypeAdapter
from typing_extensions import ReadOnly
from typing_extensions import ReadOnly, assert_never
import litellm
from litellm._logging import verbose_proxy_logger
@ -32,11 +32,17 @@ from litellm.constants import (
EMPTY_MAPPING,
LITELLM_TRUNCATED_PAYLOAD_FIELD,
LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
SPEND_CAPTURE_RATE_MAX_RANGE_DAYS,
)
from litellm.litellm_core_utils.classifier_logging import classifier_audit_fields, classifier_input_snapshot
from litellm.proxy._types import *
from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.spend_tracking.spend_capture_rate import (
ProviderBillingCredentialMissing,
ProviderBillingRequestFailed,
capture_rate_report,
)
# NOTE: Avoid module-level import from common_utils: proxy_server imports this
# module while common_utils may pull proxy_server during init, which can leave
@ -52,6 +58,7 @@ from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.verification_token_repository import (
VerificationTokenRepository,
)
from litellm.types.proxy.spend_capture_rate import CaptureRateReport, SpendCaptureProvider
if TYPE_CHECKING:
from prisma import models as prisma_models
@ -1183,6 +1190,84 @@ async def get_global_activity_exceptions(
)
@router.get(
"/spend/capture_rate",
tags=["Budget & Spend Tracking"], # mutable-ok: FastAPI tags kwarg is list-typed
dependencies=(Depends(user_api_key_auth),),
response_model=CaptureRateReport,
)
async def get_spend_capture_rate(
start_date: Annotated[date, fastapi.Query(description="First UTC day of the range, YYYY-MM-DD")],
end_date: Annotated[date, fastapi.Query(description="Last UTC day of the range, YYYY-MM-DD, inclusive")],
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
provider: Annotated[
SpendCaptureProvider,
fastapi.Query(description="Provider whose bill to compare against; needs OPENAI_ADMIN_KEY set on the proxy"),
] = "openai",
threshold: Annotated[
float, fastapi.Query(gt=0, le=1, description="Ratio under which the report flags below_threshold")
] = 0.9,
project_ids: Annotated[
list[str] | None,
fastapi.Query(
description=(
"Scope the OpenAI bill to these project ids; omit to compare against the whole organization. Captured "
"spend is never scoped, so pass every project LiteLLM's OpenAI keys belong to"
)
),
] = None,
) -> CaptureRateReport:
"""
Compare the spend LiteLLM captured for a provider against that provider's own bill, per UTC day.
Admin only. Reads the provider's billing API with the billing credential set on the proxy
(OpenAI: `OPENAI_ADMIN_KEY`) and sums `LiteLLM_DailyUserSpend` for the same days.
Example:
```
curl -H "Authorization: Bearer sk-1234" \
"http://localhost:4000/spend/capture_rate?provider=openai&start_date=2026-09-17&end_date=2026-09-23"
```
"""
from litellm.proxy.proxy_server import prisma_client
if not _is_admin_view_safe(user_api_key_dict):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only proxy admins can read the capture rate")
if prisma_client is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=CommonProxyErrors.db_not_connected_error.value
)
if end_date < start_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="end_date must not be before start_date")
if (end_date - start_date).days >= SPEND_CAPTURE_RATE_MAX_RANGE_DAYS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Date range too large; maximum is {SPEND_CAPTURE_RATE_MAX_RANGE_DAYS} days",
)
result: Final = await capture_rate_report(
prisma_client,
provider=provider,
start_date=start_date,
end_date=end_date,
threshold=threshold,
openai_project_ids=tuple(project_ids or ()),
)
match result:
case ProviderBillingCredentialMissing(env_var=env_var):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"{env_var} is not set on the proxy, so the {provider} bill cannot be read",
)
case ProviderBillingRequestFailed(detail=detail):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Could not read the {provider} bill: {detail}"
)
case CaptureRateReport():
return result
case _:
assert_never(result)
@router.get(
"/global/spend/provider",
tags=["Budget & Spend Tracking"],

View file

@ -281,6 +281,7 @@ DEFINED_PROMETHEUS_METRICS = Literal[
"litellm_guardrail_errors_total",
"litellm_guardrail_requests_total",
"litellm_zero_cost_requests_total",
"litellm_spend_capture_rate",
# Cache metrics
"litellm_cache_hits_metric",
"litellm_cache_misses_metric",
@ -600,6 +601,8 @@ class PrometheusMetricLabels:
ZERO_COST_REASON_LABEL,
)
litellm_spend_capture_rate = (UserAPIKeyLabelNames.API_PROVIDER.value,)
litellm_input_tokens_metric = [
UserAPIKeyLabelNames.END_USER.value,
UserAPIKeyLabelNames.API_KEY_HASH.value,

View file

@ -24,6 +24,7 @@ class httpxSpecialProvider(str, Enum):
Search = "search"
MCP = "mcp"
RAG = "rag"
ProviderBilling = "provider_billing"
A2AProvider = "a2a_provider"
AgentHealthCheck = "agent_health_check"
AgentKillSwitch = "agent_kill_switch"

View file

@ -0,0 +1,54 @@
"""The captured-spend to provider-bill ratio: the share of a provider's bill that went through LiteLLM and was priced.
``capture_rate = captured_spend / provider_spend`` over the same UTC days. 1.0 means LiteLLM saw and priced every
dollar the provider billed, lower means traffic reaches the provider outside LiteLLM or cost tracking drops spend,
higher means LiteLLM prices above the bill. ``None`` means the provider billed nothing, so there is no ratio.
"""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from litellm.constants import SPEND_CAPTURE_RATE_MAX_RANGE_DAYS
SpendCaptureProvider = Literal["openai"]
class SpendCaptureRateCheckSettings(BaseModel):
"""``general_settings.spend_capture_rate_check``: the daily check of captured spend against the provider bill."""
model_config = ConfigDict(frozen=True, extra="forbid")
providers: tuple[SpendCaptureProvider, ...] = Field(("openai",), min_length=1)
threshold: float = Field(0.9, gt=0, le=1)
lookback_days: int = Field(7, ge=1, le=SPEND_CAPTURE_RATE_MAX_RANGE_DAYS)
openai_project_ids: tuple[str, ...] = Field(
(),
description=(
"Scope the OpenAI bill to these project ids; empty compares against the whole organization. Captured "
"spend is never scoped, so list every project LiteLLM's OpenAI keys belong to"
),
)
class CaptureRateDay(BaseModel):
model_config = ConfigDict(frozen=True)
date: str
captured_spend: float
provider_spend: float
capture_rate: float | None
class CaptureRateReport(BaseModel):
model_config = ConfigDict(frozen=True)
provider: SpendCaptureProvider
start_date: str
end_date: str
captured_spend: float
provider_spend: float
capture_rate: float | None
threshold: float
below_threshold: bool
days: tuple[CaptureRateDay, ...]

View file

@ -0,0 +1,61 @@
import math
from typing import Final
import pytest
from prometheus_client import REGISTRY
from prometheus_client.samples import Sample
import litellm
from litellm.integrations.prometheus import PrometheusLogger
METRIC: Final = "litellm_spend_capture_rate"
def _clear_prometheus_registry() -> None:
for collector in list(REGISTRY._collector_to_names.keys()):
try:
REGISTRY.unregister(collector)
except Exception:
pass
def _samples(metric_name: str) -> list[Sample]:
return [sample for metric in REGISTRY.collect() for sample in metric.samples if sample.name == metric_name]
def test_capture_rate_gauge_holds_the_latest_rate_per_provider_and_nan_when_there_is_none() -> None:
_clear_prometheus_registry()
try:
logger: Final = PrometheusLogger()
assert _samples(METRIC) == []
logger.set_spend_capture_rate(api_provider="openai", capture_rate=0.87)
logger.set_spend_capture_rate(api_provider="openai", capture_rate=0.91)
samples: Final = _samples(METRIC)
assert [(sample.labels, sample.value) for sample in samples] == [({"api_provider": "openai"}, 0.91)]
logger.set_spend_capture_rate(api_provider="openai", capture_rate=None)
(unavailable,) = _samples(METRIC)
assert unavailable.labels == {"api_provider": "openai"} and math.isnan(unavailable.value)
finally:
_clear_prometheus_registry()
def test_capture_rate_gauge_still_records_when_api_provider_is_an_excluded_label(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "prometheus_exclude_labels", ["api_provider"])
_clear_prometheus_registry()
try:
logger: Final = PrometheusLogger()
logger.set_spend_capture_rate(api_provider="openai", capture_rate=0.42)
assert [(sample.labels, sample.value) for sample in _samples(METRIC)] == [({}, 0.42)]
logger.set_spend_capture_rate(api_provider="openai", capture_rate=None)
(unavailable,) = _samples(METRIC)
assert unavailable.labels == {} and math.isnan(unavailable.value)
finally:
_clear_prometheus_registry()

View file

@ -0,0 +1,112 @@
from collections.abc import Mapping
from datetime import date, datetime, timezone
from typing import Final
import httpx
import pytest
from litellm.constants import OPENAI_ORGANIZATION_COSTS_URL
from litellm.llms.openai.organization_costs import OpenAICostsRequestFailed, fetch_openai_daily_costs
_ADMIN_KEY: Final = "sk-admin-test"
def _utc_midnight(day: str) -> int:
return int(datetime.fromisoformat(day).replace(tzinfo=timezone.utc).timestamp())
def _bucket(day: str, *amounts: float) -> dict[str, object]:
return {
"object": "bucket",
"start_time": _utc_midnight(day),
"end_time": _utc_midnight(day) + 86400,
"results": [
{"object": "organization.costs.result", "amount": {"value": amount, "currency": "usd"}, "line_item": None}
for amount in amounts
],
}
class _FakeCostsApi:
"""Serves ``pages`` in order and records every request it saw."""
def __init__(self, *pages: dict[str, object] | httpx.Response | Exception) -> None:
self._pages = list(pages)
self.calls: list[tuple[str, Mapping[str, object], Mapping[str, str]]] = []
async def __call__(self, url: str, params: Mapping[str, object], headers: Mapping[str, str]) -> httpx.Response:
self.calls.append((url, dict(params), dict(headers)))
page = self._pages.pop(0)
if isinstance(page, Exception):
raise page
if isinstance(page, httpx.Response):
return page
return httpx.Response(200, json=page)
def _page(*buckets: dict[str, object], next_page: str | None = None) -> dict[str, object]:
return {"object": "page", "data": list(buckets), "has_more": next_page is not None, "next_page": next_page}
@pytest.mark.asyncio
async def test_openai_costs_are_summed_per_utc_day_across_pages_and_line_items():
api = _FakeCostsApi(
_page(_bucket("2026-09-20", 10.0, 2.5), _bucket("2026-09-21", 4.0), next_page="page_2"),
_page(_bucket("2026-09-22", 1.0)),
)
billed = await fetch_openai_daily_costs(date(2026, 9, 20), date(2026, 9, 22), admin_key=_ADMIN_KEY, http_get=api)
assert dict(billed) == {"2026-09-20": 12.5, "2026-09-21": 4.0, "2026-09-22": 1.0}
first, second = api.calls
assert first[0] == OPENAI_ORGANIZATION_COSTS_URL
assert first[2] == {"Authorization": f"Bearer {_ADMIN_KEY}"}
assert first[1]["start_time"] == _utc_midnight("2026-09-20")
assert first[1]["end_time"] == _utc_midnight("2026-09-23")
assert first[1]["bucket_width"] == "1d"
assert "page" not in first[1]
assert "project_ids[]" not in first[1]
assert second[1] == {**first[1], "page": "page_2"}
@pytest.mark.asyncio
async def test_openai_costs_are_scoped_to_the_configured_projects():
api = _FakeCostsApi(_page())
billed = await fetch_openai_daily_costs(
date(2026, 9, 20), date(2026, 9, 20), admin_key=_ADMIN_KEY, project_ids=("proj_a", "proj_b"), http_get=api
)
assert dict(billed) == {}
assert api.calls[0][1]["project_ids[]"] == ("proj_a", "proj_b")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"page, detail_fragment",
[
(httpx.Response(401, json={"error": {"message": "Incorrect API key provided"}}), "HTTP 401"),
(httpx.ConnectError("connection refused"), "request failed"),
({"object": "page", "data": [{"start_time": "not-a-timestamp"}]}, "unexpected response shape"),
],
)
async def test_an_unreadable_openai_bill_is_a_request_failure_not_an_exception(page, detail_fragment):
api = _FakeCostsApi(page)
billed = await fetch_openai_daily_costs(date(2026, 9, 20), date(2026, 9, 20), admin_key=_ADMIN_KEY, http_get=api)
assert isinstance(billed, OpenAICostsRequestFailed)
assert detail_fragment in billed.detail
@pytest.mark.asyncio
async def test_a_failure_on_a_later_page_fails_the_whole_read():
api = _FakeCostsApi(
_page(_bucket("2026-09-20", 10.0), next_page="page_2"),
httpx.Response(429, json={"error": {"message": "rate limited"}}),
)
billed = await fetch_openai_daily_costs(date(2026, 9, 20), date(2026, 9, 21), admin_key=_ADMIN_KEY, http_get=api)
assert isinstance(billed, OpenAICostsRequestFailed)
assert "HTTP 429" in billed.detail

View file

@ -0,0 +1,439 @@
import json
import re
from collections.abc import Mapping
from datetime import date, datetime, timezone
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import httpx
import psycopg
import pytest
from psycopg.rows import dict_row
from pydantic import ValidationError
from pytest_postgresql import factories
from litellm.constants import (
SPEND_CAPTURE_RATE_CHECK_JOB_ID,
SPEND_CAPTURE_RATE_DOCS_URL,
SPEND_CAPTURE_RATE_MAX_RANGE_DAYS,
)
from litellm.llms.openai.organization_costs import OPENAI_ADMIN_KEY_ENV_VAR
from litellm.proxy.spend_tracking.spend_capture_rate import (
ProviderBillingCredentialMissing,
ProviderBillingRequestFailed,
alert_message,
captured_spend_by_day,
compute_capture_rate,
run_scheduled_spend_capture_rate_check,
run_spend_capture_rate_check,
)
from litellm.types.proxy.spend_capture_rate import CaptureRateReport, SpendCaptureRateCheckSettings
_ADMIN_KEY: Final = "sk-admin-test"
def _utc_midnight(day: str) -> int:
return int(datetime.fromisoformat(day).replace(tzinfo=timezone.utc).timestamp())
def _bucket(day: str, *amounts: float) -> dict[str, object]:
return {
"object": "bucket",
"start_time": _utc_midnight(day),
"end_time": _utc_midnight(day) + 86400,
"results": [
{"object": "organization.costs.result", "amount": {"value": amount, "currency": "usd"}, "line_item": None}
for amount in amounts
],
}
class _FakeCostsApi:
"""Serves ``pages`` in order and records every request it saw."""
def __init__(self, *pages: dict[str, object] | httpx.Response) -> None:
self.responses = [
page if isinstance(page, httpx.Response) else httpx.Response(200, json=page) for page in pages
]
self.calls: list[tuple[str, Mapping[str, object], Mapping[str, str]]] = []
async def __call__(self, url: str, params: Mapping[str, object], headers: Mapping[str, str]) -> httpx.Response:
self.calls.append((url, dict(params), dict(headers)))
return self.responses[len(self.calls) - 1]
def _page(*buckets: dict[str, object], next_page: str | None = None) -> dict[str, object]:
return {"object": "page", "data": list(buckets), "has_more": next_page is not None, "next_page": next_page}
def _fake_prisma(rows: list[dict[str, object]]) -> MagicMock:
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(return_value=rows)
return prisma
def test_capture_rate_covers_every_day_in_the_range_and_flags_the_threshold():
report = compute_capture_rate(
provider="openai",
start_date=date(2026, 9, 20),
end_date=date(2026, 9, 22),
captured_by_day={"2026-09-20": 8.0, "2026-09-22": 1.0},
billed_by_day={"2026-09-20": 10.0, "2026-09-21": 5.0},
threshold=0.9,
)
assert [day.date for day in report.days] == ["2026-09-20", "2026-09-21", "2026-09-22"]
assert [day.capture_rate for day in report.days] == [0.8, 0.0, None]
assert report.captured_spend == 9.0
assert report.provider_spend == 15.0
assert report.capture_rate == 0.6
assert report.below_threshold is True
def test_capture_rate_at_or_above_the_threshold_is_not_flagged_and_a_zero_bill_has_no_rate():
healthy = compute_capture_rate(
provider="openai",
start_date=date(2026, 9, 20),
end_date=date(2026, 9, 20),
captured_by_day={"2026-09-20": 9.5},
billed_by_day={"2026-09-20": 10.0},
threshold=0.9,
)
over = compute_capture_rate(
provider="openai",
start_date=date(2026, 9, 20),
end_date=date(2026, 9, 20),
captured_by_day={"2026-09-20": 12.0},
billed_by_day={"2026-09-20": 10.0},
threshold=0.9,
)
unbilled = compute_capture_rate(
provider="openai",
start_date=date(2026, 9, 20),
end_date=date(2026, 9, 20),
captured_by_day={"2026-09-20": 3.0},
billed_by_day={},
threshold=0.9,
)
assert (healthy.capture_rate, healthy.below_threshold) == (0.95, False)
assert (over.capture_rate, over.below_threshold) == (1.2, False)
assert (unbilled.capture_rate, unbilled.below_threshold) == (None, False)
assert alert_message(healthy) is None
assert alert_message(over) is None
assert alert_message(unbilled) is None
def test_alert_messages_name_the_cause_and_link_the_docs():
below = compute_capture_rate(
provider="openai",
start_date=date(2026, 9, 14),
end_date=date(2026, 9, 20),
captured_by_day={"2026-09-14": 700.0},
billed_by_day={"2026-09-14": 1000.0},
threshold=0.9,
)
below_message = alert_message(below)
missing_message = alert_message(ProviderBillingCredentialMissing("openai", OPENAI_ADMIN_KEY_ENV_VAR))
failed_message = alert_message(ProviderBillingRequestFailed("openai", "HTTP 401: nope"))
assert below_message is not None and "70.0%" in below_message and "90%" in below_message
assert "$700.00" in below_message and "$1,000.00" in below_message
assert "2026-09-14 to 2026-09-20" in below_message
assert missing_message is not None and OPENAI_ADMIN_KEY_ENV_VAR in missing_message
assert failed_message is not None and "HTTP 401: nope" in failed_message
assert all(SPEND_CAPTURE_RATE_DOCS_URL in m for m in (below_message, missing_message, failed_message))
@pytest.mark.asyncio
async def test_check_reads_the_closed_window_before_today_and_publishes_the_rate(monkeypatch):
monkeypatch.setenv(OPENAI_ADMIN_KEY_ENV_VAR, _ADMIN_KEY)
api = _FakeCostsApi(_page(_bucket("2026-09-21", 100.0), _bucket("2026-09-22", 100.0)))
prisma = _fake_prisma([{"date": "2026-09-21", "spend": 95.0}, {"date": "2026-09-22", "spend": 91.0}])
alert = AsyncMock()
publish = MagicMock()
results = await run_spend_capture_rate_check(
prisma,
SpendCaptureRateCheckSettings(lookback_days=2, threshold=0.9),
alert=alert,
publish=publish,
today=date(2026, 9, 23),
http_get=api,
)
(report,) = results
assert isinstance(report, CaptureRateReport)
assert (report.start_date, report.end_date) == ("2026-09-21", "2026-09-22")
assert report.capture_rate == 0.93
publish.assert_called_once_with("openai", 0.93)
alert.assert_not_awaited()
assert api.calls[0][1]["start_time"] == _utc_midnight("2026-09-21")
assert api.calls[0][1]["end_time"] == _utc_midnight("2026-09-23")
sql, start, end, providers = prisma.db.query_raw.await_args.args
assert (start, end) == ("2026-09-21", "2026-09-22")
assert set(providers) == {"openai", "text-completion-openai"}
assert '"LiteLLM_DailyUserSpend"' in sql
@pytest.mark.asyncio
async def test_check_alerts_under_the_threshold_and_still_publishes_the_rate(monkeypatch):
monkeypatch.setenv(OPENAI_ADMIN_KEY_ENV_VAR, _ADMIN_KEY)
api = _FakeCostsApi(_page(_bucket("2026-09-22", 200.0)))
prisma = _fake_prisma([{"date": "2026-09-22", "spend": 50.0}])
alert = AsyncMock()
publish = MagicMock()
await run_spend_capture_rate_check(
prisma,
SpendCaptureRateCheckSettings(lookback_days=1),
alert=alert,
publish=publish,
today=date(2026, 9, 23),
http_get=api,
)
publish.assert_called_once_with("openai", 0.25)
alert.assert_awaited_once()
assert "25.0%" in alert.await_args.args[0]
@pytest.mark.asyncio
async def test_check_alerts_on_a_missing_admin_key_and_publishes_nothing(monkeypatch):
monkeypatch.delenv(OPENAI_ADMIN_KEY_ENV_VAR, raising=False)
api = _FakeCostsApi()
prisma = _fake_prisma([])
alert = AsyncMock()
publish = MagicMock()
(result,) = await run_spend_capture_rate_check(
prisma, SpendCaptureRateCheckSettings(), alert=alert, publish=publish, today=date(2026, 9, 23), http_get=api
)
assert result == ProviderBillingCredentialMissing("openai", OPENAI_ADMIN_KEY_ENV_VAR)
publish.assert_called_once_with("openai", None)
alert.assert_awaited_once()
assert api.calls == []
prisma.db.query_raw.assert_not_awaited()
@pytest.mark.asyncio
async def test_check_alerts_on_an_unreadable_bill_and_publishes_no_rate(monkeypatch):
monkeypatch.setenv(OPENAI_ADMIN_KEY_ENV_VAR, _ADMIN_KEY)
api = _FakeCostsApi(httpx.Response(401, json={"error": {"message": "Incorrect API key provided"}}))
prisma = _fake_prisma([{"date": "2026-09-22", "spend": 5.0}])
alert = AsyncMock()
publish = MagicMock()
(result,) = await run_spend_capture_rate_check(
prisma, SpendCaptureRateCheckSettings(), alert=alert, publish=publish, today=date(2026, 9, 23), http_get=api
)
assert result == ProviderBillingRequestFailed("openai", "HTTP 401: " + api.responses[0].text[:300])
publish.assert_called_once_with("openai", None)
alert.assert_awaited_once()
assert "HTTP 401" in alert.await_args.args[0]
prisma.db.query_raw.assert_not_awaited()
@pytest.mark.asyncio
async def test_check_publishes_no_rate_when_the_provider_billed_nothing(monkeypatch):
monkeypatch.setenv(OPENAI_ADMIN_KEY_ENV_VAR, _ADMIN_KEY)
api = _FakeCostsApi(_page())
prisma = _fake_prisma([{"date": "2026-09-22", "spend": 5.0}])
publish = MagicMock()
alert = AsyncMock()
(report,) = await run_spend_capture_rate_check(
prisma,
SpendCaptureRateCheckSettings(lookback_days=1),
alert=alert,
publish=publish,
today=date(2026, 9, 23),
http_get=api,
)
assert isinstance(report, CaptureRateReport) and report.capture_rate is None
publish.assert_called_once_with("openai", None)
alert.assert_not_awaited()
def _pod_lock(acquired: bool) -> MagicMock:
lock = MagicMock()
lock.redis_cache = MagicMock()
lock.redis_cache.async_get_cache = AsyncMock(return_value="other-pod")
lock.get_redis_lock_key = MagicMock(return_value="lock-key")
lock.acquire_lock = AsyncMock(return_value=acquired)
lock.release_lock = AsyncMock()
return lock
async def _scheduled_run(
lock: MagicMock, monkeypatch, *, captured: float, prisma: MagicMock | None = None
) -> tuple[AsyncMock, MagicMock]:
monkeypatch.setenv(OPENAI_ADMIN_KEY_ENV_VAR, _ADMIN_KEY)
alert = AsyncMock()
publish = MagicMock()
await run_scheduled_spend_capture_rate_check(
prisma or _fake_prisma([{"date": "2026-09-22", "spend": captured}]),
SpendCaptureRateCheckSettings(lookback_days=1),
pod_lock_manager=lock,
alert=alert,
publish=publish,
today=date(2026, 9, 23),
http_get=_FakeCostsApi(_page(_bucket("2026-09-22", 200.0))),
)
return alert, publish
async def _scheduled_run_under_threshold(lock: MagicMock, monkeypatch) -> tuple[AsyncMock, MagicMock]:
return await _scheduled_run(lock, monkeypatch, captured=50.0)
@pytest.mark.asyncio
async def test_a_healthy_scheduled_check_publishes_and_never_touches_the_alert_lock(monkeypatch):
lock = _pod_lock(acquired=False)
alert, publish = await _scheduled_run(lock, monkeypatch, captured=190.0)
publish.assert_called_once_with("openai", 0.95)
alert.assert_not_awaited()
lock.acquire_lock.assert_not_awaited()
@pytest.mark.asyncio
async def test_a_scheduled_check_that_fails_never_claims_the_alert_window(monkeypatch):
lock = _pod_lock(acquired=True)
prisma = MagicMock()
prisma.db.query_raw = AsyncMock(side_effect=RuntimeError("database gone"))
with pytest.raises(RuntimeError, match="database gone"):
await _scheduled_run(lock, monkeypatch, captured=0.0, prisma=prisma)
lock.acquire_lock.assert_not_awaited()
@pytest.mark.asyncio
async def test_scheduled_check_publishes_but_stays_quiet_when_another_pod_holds_the_alert_window(monkeypatch):
lock = _pod_lock(acquired=False)
alert, publish = await _scheduled_run_under_threshold(lock, monkeypatch)
publish.assert_called_once_with("openai", 0.25)
alert.assert_not_awaited()
lock.release_lock.assert_not_awaited()
@pytest.mark.asyncio
async def test_scheduled_check_alerts_and_keeps_the_lock_until_it_expires_when_it_wins(monkeypatch):
lock = _pod_lock(acquired=True)
alert, publish = await _scheduled_run_under_threshold(lock, monkeypatch)
lock.acquire_lock.assert_awaited_once_with(cronjob_id=SPEND_CAPTURE_RATE_CHECK_JOB_ID, ttl=900)
lock.release_lock.assert_not_awaited()
publish.assert_called_once_with("openai", 0.25)
alert.assert_awaited_once()
@pytest.mark.asyncio
async def test_scheduled_check_alerts_when_the_lock_cannot_be_acquired_or_read(monkeypatch):
lock = _pod_lock(acquired=False)
lock.redis_cache.async_get_cache = AsyncMock(side_effect=ConnectionError("redis down"))
alert, publish = await _scheduled_run_under_threshold(lock, monkeypatch)
publish.assert_called_once_with("openai", 0.25)
alert.assert_awaited_once()
@pytest.mark.asyncio
async def test_scheduled_check_alerts_without_a_lock_manager(monkeypatch):
lock = _pod_lock(acquired=False)
lock.redis_cache = None
alert, publish = await _scheduled_run_under_threshold(lock, monkeypatch)
lock.acquire_lock.assert_not_awaited()
publish.assert_called_once_with("openai", 0.25)
alert.assert_awaited_once()
def test_settings_reject_typos_and_out_of_range_values():
with pytest.raises(ValidationError, match="threshhold"):
SpendCaptureRateCheckSettings.model_validate({"threshhold": 0.9})
with pytest.raises(ValidationError, match="threshold"):
SpendCaptureRateCheckSettings.model_validate({"threshold": 1.5})
with pytest.raises(ValidationError, match="providers"):
SpendCaptureRateCheckSettings.model_validate({"providers": []})
with pytest.raises(ValidationError, match="providers"):
SpendCaptureRateCheckSettings.model_validate({"providers": ["anthropic"]})
with pytest.raises(ValidationError, match="lookback_days"):
SpendCaptureRateCheckSettings.model_validate({"lookback_days": SPEND_CAPTURE_RATE_MAX_RANGE_DAYS + 1})
parsed = SpendCaptureRateCheckSettings.model_validate(
json.loads('{"providers": ["openai"], "threshold": 0.8, "lookback_days": 3, "openai_project_ids": ["p"]}')
)
assert (parsed.threshold, parsed.lookback_days, parsed.openai_project_ids) == (0.8, 3, ("p",))
_capture_postgresql_proc: Final = factories.postgresql_proc()
_capture_postgresql: Final = factories.postgresql("_capture_postgresql_proc")
_DAILY_USER_SPEND_DDL: Final = """
CREATE TABLE "LiteLLM_DailyUserSpend" (
id TEXT PRIMARY KEY,
date TEXT NOT NULL,
custom_llm_provider TEXT,
spend DOUBLE PRECISION DEFAULT 0
)
"""
class _PsycopgPrisma:
"""``prisma_client.db.query_raw`` on a real connection, with ``$n`` placeholders converted for psycopg."""
def __init__(self, conn: psycopg.Connection) -> None:
self.db = self
self._conn = conn
async def query_raw(self, sql: str, *params: object) -> list[dict[str, object]]:
converted: Final = re.sub(r"\$(\d+)", r"%(p\1)s", sql)
with self._conn.cursor(row_factory=dict_row) as cur:
cur.execute(
converted, # pyright: ignore[reportArgumentType] # psycopg stubs want a literal-typed query
{f"p{i}": list(v) if isinstance(v, tuple) else v for i, v in enumerate(params, start=1)},
)
return cur.fetchall()
@pytest.mark.asyncio
async def test_captured_spend_sums_only_the_openai_billed_providers_inside_the_window(
_capture_postgresql: psycopg.Connection,
):
conn: Final = _capture_postgresql
conn.execute(_DAILY_USER_SPEND_DDL) # pyright: ignore[reportArgumentType] # DDL literal
rows: Final = (
("2026-09-19", "openai", 1.0),
("2026-09-20", "openai", 2.0),
("2026-09-20", "openai", 3.0),
("2026-09-20", "text-completion-openai", 0.5),
("2026-09-20", "anthropic", 100.0),
("2026-09-21", "azure", 100.0),
("2026-09-22", "openai", 4.0),
)
for index, (day, provider, spend) in enumerate(rows):
conn.execute(
'INSERT INTO "LiteLLM_DailyUserSpend" (id, date, custom_llm_provider, spend) VALUES (%s, %s, %s, %s)',
(f"row-{index}", day, provider, spend),
)
conn.commit()
captured = await captured_spend_by_day(
_PsycopgPrisma(conn), # pyright: ignore[reportArgumentType] # duck-typed prisma for the raw query
litellm_providers=("openai", "text-completion-openai"),
start_date=date(2026, 9, 20),
end_date=date(2026, 9, 21),
)
assert dict(captured) == {"2026-09-20": 5.5}

View file

@ -14,7 +14,7 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Final
from unittest import mock
from unittest.mock import AsyncMock, MagicMock, create_autospec, mock_open, patch
from unittest.mock import AsyncMock, MagicMock, call, create_autospec, mock_open, patch
import click
import fastapi.routing
@ -3524,9 +3524,7 @@ async def test_load_config_without_role_permissions_leaves_every_role_unrestrict
from litellm.proxy.proxy_server import ProxyConfig
config_file: Final = tmp_path / "config.yaml"
config_file.write_text(
yaml.dump({"model_list": [], "general_settings": {"max_parallel_requests": 7}})
)
config_file.write_text(yaml.dump({"model_list": [], "general_settings": {"max_parallel_requests": 7}}))
_, _, settings = await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file))
@ -3556,7 +3554,9 @@ async def test_load_config_rejects_malformed_role_permissions(tmp_path):
@pytest.mark.asyncio
async def test_load_config_compiles_key_alias_pattern_at_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
async def test_load_config_compiles_key_alias_pattern_at_startup(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.proxy.proxy_server import ProxyConfig
monkeypatch.setattr(litellm, "key_alias_pattern", None)
@ -3592,9 +3592,7 @@ def test_os_environ_resolution_leaves_the_config_layer_holding_the_reference(mon
assert resolved["general_settings"]["coordination_redis"]["password"] == "sk-nested-value"
assert resolved["general_settings"]["master_key"] == "sk-nested-value"
assert proxy_config.settings.config_value("master_key") == "os.environ/PROOF_NESTED_SECRET"
assert proxy_config.settings.config_value("coordination_redis") == {
"password": "os.environ/PROOF_NESTED_SECRET"
}
assert proxy_config.settings.config_value("coordination_redis") == {"password": "os.environ/PROOF_NESTED_SECRET"}
def test_os_environ_resolution_reaches_dicts_nested_in_a_list(monkeypatch):
@ -5754,7 +5752,9 @@ async def test_boot_warns_that_a_shadowed_database_value_will_never_apply(tmp_pa
config_path.write_text(
yaml.safe_dump({"model_list": [], "general_settings": {"allowed_ips": ["1.2.3.4"], "max_file_size_mb": 5}})
)
db_row: Final = types.SimpleNamespace(param_value={"allowed_ips": ["1.2.3.4", "5.6.7.8"], "max_parallel_requests": 7})
db_row: Final = types.SimpleNamespace(
param_value={"allowed_ips": ["1.2.3.4", "5.6.7.8"], "max_parallel_requests": 7}
)
async def read_config_row(_prisma_client, param_name):
return db_row if param_name == "general_settings" else None
@ -7957,7 +7957,9 @@ async def test_update_general_settings_clearing_a_db_override_falls_back_to_the_
proxy_config.settings.load_yaml({"maximum_spend_logs_cleanup_run_budget": "90s"})
with patch("litellm.proxy.proxy_server.general_settings", proxy_config.settings):
await proxy_config._update_general_settings(db_general_settings={"maximum_spend_logs_cleanup_run_budget": "30s"})
await proxy_config._update_general_settings(
db_general_settings={"maximum_spend_logs_cleanup_run_budget": "30s"}
)
await proxy_config._update_general_settings(db_general_settings={"store_model_in_db": True})
import litellm.proxy.proxy_server as ps
@ -8000,10 +8002,18 @@ async def test_update_general_settings_keeps_yaml_pass_through_endpoints_next_to
request.query_params = {}
return request
settings: Final = patch("litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [yaml_endpoint]}) # test-quality-ok: the method reads this module global; no injection seam
yaml_endpoints: Final = patch("litellm.proxy.proxy_server.config_passthrough_endpoints", [yaml_endpoint]) # test-quality-ok: module global holding the YAML endpoints the fix merges in
initialize: Final = patch("litellm.proxy.proxy_server.initialize_pass_through_endpoints", AsyncMock()) # test-quality-ok: route registration needs the FastAPI app; auth is the observable here
master_key: Final = patch("litellm.proxy.proxy_server.master_key", "sk-master") # test-quality-ok: a set master key is what makes a missing Authorization header a 401
settings: Final = patch(
"litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [yaml_endpoint]}
) # test-quality-ok: the method reads this module global; no injection seam
yaml_endpoints: Final = patch(
"litellm.proxy.proxy_server.config_passthrough_endpoints", [yaml_endpoint]
) # test-quality-ok: module global holding the YAML endpoints the fix merges in
initialize: Final = patch(
"litellm.proxy.proxy_server.initialize_pass_through_endpoints", AsyncMock()
) # test-quality-ok: route registration needs the FastAPI app; auth is the observable here
master_key: Final = patch(
"litellm.proxy.proxy_server.master_key", "sk-master"
) # test-quality-ok: a set master key is what makes a missing Authorization header a 401
with settings, yaml_endpoints, initialize, master_key:
await ProxyConfig()._update_general_settings(db_general_settings={"pass_through_endpoints": [db_endpoint]})
@ -8050,10 +8060,18 @@ async def test_update_general_settings_db_pass_through_endpoint_cannot_override_
request.headers = {}
request.query_params = {}
settings: Final = patch("litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [yaml_endpoint]}) # test-quality-ok: the method reads this module global; no injection seam
yaml_endpoints: Final = patch("litellm.proxy.proxy_server.config_passthrough_endpoints", [yaml_endpoint]) # test-quality-ok: module global holding the YAML endpoints the fix merges in
initialize: Final = patch("litellm.proxy.proxy_server.initialize_pass_through_endpoints", AsyncMock()) # test-quality-ok: route registration needs the FastAPI app; auth is the observable here
master_key: Final = patch("litellm.proxy.proxy_server.master_key", "sk-master") # test-quality-ok: a set master key is what makes a missing Authorization header a 401
settings: Final = patch(
"litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [yaml_endpoint]}
) # test-quality-ok: the method reads this module global; no injection seam
yaml_endpoints: Final = patch(
"litellm.proxy.proxy_server.config_passthrough_endpoints", [yaml_endpoint]
) # test-quality-ok: module global holding the YAML endpoints the fix merges in
initialize: Final = patch(
"litellm.proxy.proxy_server.initialize_pass_through_endpoints", AsyncMock()
) # test-quality-ok: route registration needs the FastAPI app; auth is the observable here
master_key: Final = patch(
"litellm.proxy.proxy_server.master_key", "sk-master"
) # test-quality-ok: a set master key is what makes a missing Authorization header a 401
with settings, yaml_endpoints, initialize, master_key:
await ProxyConfig()._update_general_settings(db_general_settings={"pass_through_endpoints": [db_endpoint]})
@ -8087,11 +8105,19 @@ async def test_deleting_the_stored_pass_through_row_takes_the_route_out_of_servi
prior_registry: Final = dict(_registered_pass_through_routes)
def live_routes() -> set[str]:
return {route for route in InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes() if path in route}
return {
route for route in InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes() if path in route
}
settings: Final = patch("litellm.proxy.proxy_server.general_settings", {}) # test-quality-ok: the method reads this module global; no injection seam
yaml_endpoints: Final = patch("litellm.proxy.proxy_server.config_passthrough_endpoints", None) # test-quality-ok: module global holding the YAML endpoints; this case has none
app_routes: Final = patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.SafeRouteAdder.add_api_route_if_not_exists") # test-quality-ok: the registry is the observable; a real route would stay on the shared FastAPI app for the rest of the xdist worker
settings: Final = patch(
"litellm.proxy.proxy_server.general_settings", {}
) # test-quality-ok: the method reads this module global; no injection seam
yaml_endpoints: Final = patch(
"litellm.proxy.proxy_server.config_passthrough_endpoints", None
) # test-quality-ok: module global holding the YAML endpoints; this case has none
app_routes: Final = patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.SafeRouteAdder.add_api_route_if_not_exists"
) # test-quality-ok: the registry is the observable; a real route would stay on the shared FastAPI app for the rest of the xdist worker
try:
with settings, yaml_endpoints, app_routes:
pc = ProxyConfig()
@ -8132,9 +8158,15 @@ async def test_a_stored_pass_through_row_never_disturbs_the_config_declared_rout
registered: Final = InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
return {path for path in (config_path, db_path) if any(path in route for route in registered)}
settings: Final = patch("litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [config_endpoint]}) # test-quality-ok: the method reads this module global; no injection seam
yaml_endpoints: Final = patch("litellm.proxy.proxy_server.config_passthrough_endpoints", [config_endpoint]) # test-quality-ok: module global holding the YAML endpoints the reload merges in
app_routes: Final = patch("litellm.proxy.pass_through_endpoints.pass_through_endpoints.SafeRouteAdder.add_api_route_if_not_exists") # test-quality-ok: the registry is the observable; a real route would stay on the shared FastAPI app for the rest of the xdist worker
settings: Final = patch(
"litellm.proxy.proxy_server.general_settings", {"pass_through_endpoints": [config_endpoint]}
) # test-quality-ok: the method reads this module global; no injection seam
yaml_endpoints: Final = patch(
"litellm.proxy.proxy_server.config_passthrough_endpoints", [config_endpoint]
) # test-quality-ok: module global holding the YAML endpoints the reload merges in
app_routes: Final = patch(
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.SafeRouteAdder.add_api_route_if_not_exists"
) # test-quality-ok: the registry is the observable; a real route would stay on the shared FastAPI app for the rest of the xdist worker
try:
with settings, yaml_endpoints, app_routes:
await initialize_pass_through_endpoints(pass_through_endpoints=[config_endpoint])
@ -9263,9 +9295,7 @@ async def test_increment_spend_counters_finalizes_after_unreserved_increments():
async def assert_reservation_not_finalized_yet(**kwargs):
assert budget_reservation["finalized"] is False
incremented_counters.append(kwargs["counter_key"])
return ps.PendingSpendIncrement(
counter_key=kwargs["counter_key"], increment=kwargs["increment"]
)
return ps.PendingSpendIncrement(counter_key=kwargs["counter_key"], increment=kwargs["increment"])
import litellm.proxy.proxy_server as ps
@ -10854,9 +10884,15 @@ async def _lit6973_drive_realtime_session(
side_effect=pre_call_error, return_value=({"model": "vertex_ai/gemini-live-2.5-flash"}, logging_obj)
)
ws: Final = websocket if websocket is not None else _lit6973_fake_realtime_ws()
can_call = patch.object(ps, "can_key_call_resolved_model", new=AsyncMock(side_effect=model_access_error)) # test-quality-ok: no HTTP boundary; fakes in-process auth to reach the exit under test
pre = patch.object(ps.ProxyBaseLLMRequestProcessing, "common_processing_pre_call_logic", new=pre_call) # test-quality-ok: fakes phase-1 wiring; assertion checks observable reservation state
route = patch.object(ps, "route_request", new=AsyncMock(return_value=fake_llm_call())) # test-quality-ok: fakes the relay whose success/refusal outcome the endpoint reads off the logging object
can_call = patch.object(
ps, "can_key_call_resolved_model", new=AsyncMock(side_effect=model_access_error)
) # test-quality-ok: no HTTP boundary; fakes in-process auth to reach the exit under test
pre = patch.object(
ps.ProxyBaseLLMRequestProcessing, "common_processing_pre_call_logic", new=pre_call
) # test-quality-ok: fakes phase-1 wiring; assertion checks observable reservation state
route = patch.object(
ps, "route_request", new=AsyncMock(return_value=fake_llm_call())
) # test-quality-ok: fakes the relay whose success/refusal outcome the endpoint reads off the logging object
with can_call, pre, route:
await ps.realtime_websocket_endpoint(
websocket=ws,
@ -10988,13 +11024,9 @@ async def _lit6463_drive_realtime_session_holding_a_max_parallel_slot(
from litellm.proxy.utils import InternalUsageCache
dual_cache: Final = DualCache()
await dual_cache.async_set_cache(
key=_LIT6463_COUNTER_KEY, value={"slot-1": 1.0, "slot-2": 2.0}, local_only=True
)
await dual_cache.async_set_cache(key=_LIT6463_COUNTER_KEY, value={"slot-1": 1.0, "slot-2": 2.0}, local_only=True)
limiter: Final = _PROXY_MaxParallelRequestsHandler_v3(internal_usage_cache=InternalUsageCache(dual_cache))
stash: Final = RequestRateLimiterStash(
parallel_slot={"slot_id": "slot-1", "counter_keys": [_LIT6463_COUNTER_KEY]}
)
stash: Final = RequestRateLimiterStash(parallel_slot={"slot_id": "slot-1", "counter_keys": [_LIT6463_COUNTER_KEY]})
reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []}
stash_token: Final = _request_stash.set(stash)
@ -11046,9 +11078,7 @@ async def test_successful_realtime_session_leaves_the_max_parallel_slot_for_the_
limiter's integer in-memory fallback, double-decrement the counter so the key
admits more sessions than max_parallel_requests allows. With the success stamp
present the route leaves the slot and the stash alone."""
dual_cache, stash = await _lit6463_drive_realtime_session_holding_a_max_parallel_slot(
backend_logged_success=True
)
dual_cache, stash = await _lit6463_drive_realtime_session_holding_a_max_parallel_slot(backend_logged_success=True)
assert await dual_cache.async_get_cache(key=_LIT6463_COUNTER_KEY, local_only=True) == {
"slot-1": 1.0,
@ -11094,8 +11124,12 @@ async def test_release_or_invalidate_falls_back_to_invalidating_the_counters():
async def _record(counter_key: str) -> None:
invalidated.append(counter_key)
failing_release = patch.object(br, "release_budget_reservation", new=AsyncMock(side_effect=RuntimeError("counter store down"))) # test-quality-ok: forces the failure branch; assertion observes which counter key got invalidated
sink = patch.object(ps, "_invalidate_spend_counter", new=_record) # test-quality-ok: fakes the counter-store sink so the invalidated key is observable
failing_release = patch.object(
br, "release_budget_reservation", new=AsyncMock(side_effect=RuntimeError("counter store down"))
) # test-quality-ok: forces the failure branch; assertion observes which counter key got invalidated
sink = patch.object(
ps, "_invalidate_spend_counter", new=_record
) # test-quality-ok: fakes the counter-store sink so the invalidated key is observable
with failing_release, sink:
await br.release_or_invalidate_budget_reservation(budget_reservation=reservation)
@ -11111,8 +11145,12 @@ async def test_release_or_invalidate_finalizes_even_when_the_invalidate_fallback
from litellm.proxy.spend_tracking import budget_reservation as br
reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []}
failing_release = patch.object(br, "release_budget_reservation", new=AsyncMock(side_effect=RuntimeError("counter store down"))) # test-quality-ok: forces the fallback branch
failing_invalidate = patch.object(br, "invalidate_budget_reservation_counters", new=AsyncMock(side_effect=RuntimeError("still down"))) # test-quality-ok: forces the fallback itself to fail
failing_release = patch.object(
br, "release_budget_reservation", new=AsyncMock(side_effect=RuntimeError("counter store down"))
) # test-quality-ok: forces the fallback branch
failing_invalidate = patch.object(
br, "invalidate_budget_reservation_counters", new=AsyncMock(side_effect=RuntimeError("still down"))
) # test-quality-ok: forces the fallback itself to fail
with failing_release, failing_invalidate:
await br.release_or_invalidate_budget_reservation(budget_reservation=reservation)
@ -12470,9 +12508,7 @@ async def test_update_config_general_settings_refuses_a_key_the_config_file_decl
admin = UserAPIKeyAuth(api_key="hashed-admin", user_id="admin-1", user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(HTTPException) as excinfo:
await update_config_general_settings(
data=ConfigFieldUpdate(
field_name="max_parallel_requests", field_value=999, config_type="general_settings"
),
data=ConfigFieldUpdate(field_name="max_parallel_requests", field_value=999, config_type="general_settings"),
user_api_key_dict=admin,
)
@ -14038,9 +14074,15 @@ async def test_moderations_response_carries_litellm_call_id_header():
user_api_key_dict = UserAPIKeyAuth(api_key="sk-test", spend=0.0)
with (
patch.object(proxy_server_module, "add_litellm_data_to_request", new=passthrough_add_litellm_data), # test-quality-ok: the route reads this module global, no injection point
patch.object(proxy_server_module, "route_request", new=AsyncMock(return_value=fake_llm_call())), # test-quality-ok: fakes the provider call so the response headers assembled by the real route are observable
patch.object(proxy_server_module, "proxy_logging_obj") as mock_logging, # test-quality-ok: module global, no injection point
patch.object(
proxy_server_module, "add_litellm_data_to_request", new=passthrough_add_litellm_data
), # test-quality-ok: the route reads this module global, no injection point
patch.object(
proxy_server_module, "route_request", new=AsyncMock(return_value=fake_llm_call())
), # test-quality-ok: fakes the provider call so the response headers assembled by the real route are observable
patch.object(
proxy_server_module, "proxy_logging_obj"
) as mock_logging, # test-quality-ok: module global, no injection point
):
mock_logging.pre_call_hook = AsyncMock(side_effect=lambda user_api_key_dict, data, call_type: data)
mock_logging.update_request_status = AsyncMock()
@ -14077,9 +14119,15 @@ async def test_moderations_failure_log_carries_the_callers_litellm_call_id(caplo
verbose_proxy_logger.propagate = True
try:
with (
patch.object(proxy_server_module, "add_litellm_data_to_request", new=passthrough_add_litellm_data), # test-quality-ok: the route reads this module global, no injection point
patch.object(proxy_server_module, "route_request", new=AsyncMock(side_effect=Exception("bad key"))), # test-quality-ok: fakes the provider failure so the real route's error log is observable
patch.object(proxy_server_module, "proxy_logging_obj", new=fake_logging), # test-quality-ok: module global, no injection point
patch.object(
proxy_server_module, "add_litellm_data_to_request", new=passthrough_add_litellm_data
), # test-quality-ok: the route reads this module global, no injection point
patch.object(
proxy_server_module, "route_request", new=AsyncMock(side_effect=Exception("bad key"))
), # test-quality-ok: fakes the provider failure so the real route's error log is observable
patch.object(
proxy_server_module, "proxy_logging_obj", new=fake_logging
), # test-quality-ok: module global, no injection point
caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"),
pytest.raises(ProxyException) as raised,
):
@ -14112,7 +14160,9 @@ async def test_moderations_unparseable_body_bills_the_callers_litellm_call_id():
fake_logging.post_call_failure_hook = AsyncMock()
with (
patch.object(proxy_server_module, "proxy_logging_obj", new=fake_logging), # test-quality-ok: module global, no injection point
patch.object(
proxy_server_module, "proxy_logging_obj", new=fake_logging
), # test-quality-ok: module global, no injection point
pytest.raises(ProxyException) as raised,
):
await proxy_server_module.moderations(
@ -14140,8 +14190,12 @@ async def test_moderations_already_shaped_failure_answers_with_the_callers_litel
fake_logging.post_call_failure_hook = AsyncMock()
with (
patch.object(proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc)), # test-quality-ok: the route reads this module global, no injection point
patch.object(proxy_server_module, "proxy_logging_obj", new=fake_logging), # test-quality-ok: module global, no injection point
patch.object(
proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc)
), # test-quality-ok: the route reads this module global, no injection point
patch.object(
proxy_server_module, "proxy_logging_obj", new=fake_logging
), # test-quality-ok: module global, no injection point
pytest.raises(ProxyException) as raised,
):
await proxy_server_module.moderations(
@ -14176,8 +14230,12 @@ async def test_audio_speech_already_shaped_failure_answers_with_the_callers_lite
fake_logging.post_call_failure_hook = AsyncMock()
with (
patch.object(proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc)), # test-quality-ok: the route reads this module global, no injection point
patch.object(proxy_server_module, "proxy_logging_obj", new=fake_logging), # test-quality-ok: module global, no injection point
patch.object(
proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc)
), # test-quality-ok: the route reads this module global, no injection point
patch.object(
proxy_server_module, "proxy_logging_obj", new=fake_logging
), # test-quality-ok: module global, no injection point
pytest.raises(type(exc)) as raised,
):
await proxy_server_module.audio_speech(
@ -14955,14 +15013,18 @@ async def test_token_counter_loads_a_custom_tokenizer_off_the_event_loop(monkeyp
{
"model_name": "self-hosted",
"litellm_params": {"model": "openai/self-hosted-model", "api_base": "http://localhost:8080/v1"},
"model_info": {"custom_tokenizer": {"identifier": "my-org/tokenizer", "revision": "main", "auth_token": None}},
"model_info": {
"custom_tokenizer": {"identifier": "my-org/tokenizer", "revision": "main", "auth_token": None}
},
}
]
),
)
response, took, lags = await timed_with_loop_lags(
lambda: proxy_server_module.token_counter(TokenCountRequest(model="self-hosted", prompt="count me off the loop"))
lambda: proxy_server_module.token_counter(
TokenCountRequest(model="self-hosted", prompt="count me off the loop")
)
)
assert response.tokenizer_type == "huggingface_tokenizer"
@ -15186,3 +15248,123 @@ async def test_initialize_jwt_auth_leaves_the_declared_jwtauth_mapping_unresolve
assert declared["team_id_jwt_field"] == "os.environ/JWT_TEAM_FIELD"
assert proxy_server_module.jwt_handler.litellm_jwtauth.team_id_jwt_field == "resolved-team-field"
def test_spend_capture_rate_check_job_validates_the_boot_settings_and_reads_them_again_on_every_run(monkeypatch):
from pydantic import ValidationError
from litellm.constants import SPEND_CAPTURE_RATE_CHECK_JOB_ID
from litellm.proxy.proxy_server import ProxyStartupEvent
scheduler = MagicMock()
general_settings: dict[str, object] = {}
seen_settings = []
async def fake_scheduled_check(prisma_client, settings, *, pod_lock_manager, alert, publish):
seen_settings.append(settings)
return ()
monkeypatch.setattr("litellm.proxy.proxy_server.run_scheduled_spend_capture_rate_check", fake_scheduled_check)
ProxyStartupEvent._initialize_spend_capture_rate_check_job(
scheduler=scheduler,
proxy_logging_obj=MagicMock(),
prisma_client=MagicMock(),
read_general_settings=lambda: general_settings,
)
scheduler.add_job.assert_called_once()
assert scheduler.add_job.call_args.kwargs["id"] == SPEND_CAPTURE_RATE_CHECK_JOB_ID
check = scheduler.add_job.call_args.args[0]
asyncio.run(check())
assert seen_settings == []
general_settings["spend_capture_rate_check"] = {"providers": ["openai"], "threshold": 0.85}
asyncio.run(check())
general_settings["spend_capture_rate_check"] = {"threshold": 0.7, "lookback_days": 3}
asyncio.run(check())
assert [(s.threshold, s.lookback_days) for s in seen_settings] == [(0.85, 7), (0.7, 3)]
with pytest.raises(ValidationError, match="threshhold"):
ProxyStartupEvent._initialize_spend_capture_rate_check_job(
scheduler=scheduler,
proxy_logging_obj=MagicMock(),
prisma_client=MagicMock(),
read_general_settings=lambda: {"spend_capture_rate_check": {"threshhold": 0.85}},
)
@pytest.mark.asyncio
async def test_spend_capture_rate_check_job_publishes_to_prometheus_and_alerts(monkeypatch):
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy.proxy_server import ProxyStartupEvent
scheduler = MagicMock()
proxy_logging = MagicMock()
proxy_logging.alerting_handler = AsyncMock()
proxy_logging.db_spend_update_writer.pod_lock_manager = None
prometheus = MagicMock(spec=PrometheusLogger)
monkeypatch.setattr(
litellm.logging_callback_manager, "get_custom_loggers_for_type", lambda callback_type: [prometheus]
)
async def fake_scheduled_check(prisma_client, settings, *, pod_lock_manager, alert, publish):
publish("openai", 0.42)
publish("openai", None)
await alert("under the threshold")
return ()
monkeypatch.setattr("litellm.proxy.proxy_server.run_scheduled_spend_capture_rate_check", fake_scheduled_check)
ProxyStartupEvent._initialize_spend_capture_rate_check_job(
scheduler=scheduler,
proxy_logging_obj=proxy_logging,
prisma_client=MagicMock(),
read_general_settings=lambda: {"spend_capture_rate_check": {}},
)
await scheduler.add_job.call_args.args[0]()
assert prometheus.set_spend_capture_rate.call_args_list == [
call(api_provider="openai", capture_rate=0.42),
call(api_provider="openai", capture_rate=None),
]
proxy_logging.alerting_handler.assert_awaited_once()
assert proxy_logging.alerting_handler.await_args.kwargs["message"] == "under the threshold"
assert proxy_logging.alerting_handler.await_args.kwargs["level"] == "High"
@pytest.mark.asyncio
async def test_spend_capture_rate_check_job_clears_the_gauge_once_the_setting_is_removed(monkeypatch):
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy.proxy_server import ProxyStartupEvent
scheduler = MagicMock()
general_settings: dict[str, object] = {"spend_capture_rate_check": {}}
prometheus = MagicMock(spec=PrometheusLogger)
monkeypatch.setattr(
litellm.logging_callback_manager, "get_custom_loggers_for_type", lambda callback_type: [prometheus]
)
scheduled_checks = []
async def fake_scheduled_check(prisma_client, settings, *, pod_lock_manager, alert, publish):
scheduled_checks.append(settings)
publish("openai", 0.97)
return ()
monkeypatch.setattr("litellm.proxy.proxy_server.run_scheduled_spend_capture_rate_check", fake_scheduled_check)
ProxyStartupEvent._initialize_spend_capture_rate_check_job(
scheduler=scheduler,
proxy_logging_obj=MagicMock(),
prisma_client=MagicMock(),
read_general_settings=lambda: general_settings,
)
check = scheduler.add_job.call_args.args[0]
await check()
del general_settings["spend_capture_rate_check"]
await check()
assert len(scheduled_checks) == 1
assert prometheus.set_spend_capture_rate.call_args_list == [
call(api_provider="openai", capture_rate=0.97),
call(api_provider="openai", capture_rate=None),
]

View file

@ -14615,6 +14615,34 @@ export interface paths {
patch?: never;
trace?: never;
};
"/spend/capture_rate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Spend Capture Rate
* @description Compare the spend LiteLLM captured for a provider against that provider's own bill, per UTC day.
*
* Admin only. Reads the provider's billing API with the billing credential set on the proxy
* (OpenAI: `OPENAI_ADMIN_KEY`) and sums `LiteLLM_DailyUserSpend` for the same days.
*
* Example:
* ```
* curl -H "Authorization: Bearer sk-1234" "http://localhost:4000/spend/capture_rate?provider=openai&start_date=2026-09-17&end_date=2026-09-23"
* ```
*/
get: operations["get_spend_capture_rate_spend_capture_rate_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/spend/keys": {
parameters: {
query?: never;
@ -26859,6 +26887,41 @@ export interface components {
*/
threshold_step: number;
};
/** CaptureRateDay */
CaptureRateDay: {
/** Capture Rate */
capture_rate: number | null;
/** Captured Spend */
captured_spend: number;
/** Date */
date: string;
/** Provider Spend */
provider_spend: number;
};
/** CaptureRateReport */
CaptureRateReport: {
/** Below Threshold */
below_threshold: boolean;
/** Capture Rate */
capture_rate: number | null;
/** Captured Spend */
captured_spend: number;
/** Days */
days: components["schemas"]["CaptureRateDay"][];
/** End Date */
end_date: string;
/**
* Provider
* @constant
*/
provider: "openai";
/** Provider Spend */
provider_spend: number;
/** Start Date */
start_date: string;
/** Threshold */
threshold: number;
};
/** ChangePasswordRequest */
ChangePasswordRequest: {
/** Current Password */
@ -28390,6 +28453,8 @@ export interface components {
reject_clientside_metadata_tags?: boolean | null;
/** @description Spreads the proxy's scheduled background jobs (spend flushes, budget resets, config reloads, exports) across a window instead of firing them together on every replica. On by default; set to tune the window, pin a job, or turn it off. */
scheduled_job_stagger?: components["schemas"]["ScheduledJobStaggerSettings"] | null;
/** @description Daily check of the spend LiteLLM captured against the provider's own bill (OpenAI via OPENAI_ADMIN_KEY). Publishes litellm_spend_capture_rate per provider and alerts when the ratio over the lookback window falls under the threshold (default 0.9). Off unless set. */
spend_capture_rate_check?: components["schemas"]["SpendCaptureRateCheckSettings"] | null;
/**
* Store Model In Db
* @description If True, models and config are stored in and loaded from the database. Default is False.
@ -42370,6 +42435,35 @@ export interface components {
/** Model */
model?: string | null;
};
/**
* SpendCaptureRateCheckSettings
* @description ``general_settings.spend_capture_rate_check``: the daily check of captured spend against the provider bill.
*/
SpendCaptureRateCheckSettings: {
/**
* Lookback Days
* @default 7
*/
lookback_days: number;
/**
* Openai Project Ids
* @description Scope the OpenAI bill to these project ids; empty compares against the whole organization. Captured spend is never scoped, so list every project LiteLLM's OpenAI keys belong to
* @default []
*/
openai_project_ids: string[];
/**
* Providers
* @default [
* "openai"
* ]
*/
providers: "openai"[];
/**
* Threshold
* @default 0.9
*/
threshold: number;
};
/** SpendMetrics */
SpendMetrics: {
/**
@ -65624,6 +65718,46 @@ export interface operations {
};
};
};
get_spend_capture_rate_spend_capture_rate_get: {
parameters: {
query: {
/** @description First UTC day of the range, YYYY-MM-DD */
start_date: string;
/** @description Last UTC day of the range, YYYY-MM-DD, inclusive */
end_date: string;
/** @description Provider whose bill to compare against; needs OPENAI_ADMIN_KEY set on the proxy */
provider?: "openai";
/** @description Ratio under which the report flags below_threshold */
threshold?: number;
/** @description Scope the OpenAI bill to these project ids; omit to compare against the whole organization. Captured spend is never scoped, so pass every project LiteLLM's OpenAI keys belong to */
project_ids?: string[] | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CaptureRateReport"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
spend_key_fn_spend_keys_get: {
parameters: {
query?: never;