This commit is contained in:
atuln98 2026-10-04 12:47:46 -07:00 • committed by GitHub
commit cbc7273d5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 758 additions and 127 deletions

View file

@ -0,0 +1,6 @@
ALTER TABLE "LiteLLM_DailyUserSpend" ADD COLUMN IF NOT EXISTS "timed_completion_tokens" BIGINT;
ALTER TABLE "LiteLLM_DailyOrganizationSpend" ADD COLUMN IF NOT EXISTS "timed_completion_tokens" BIGINT;
ALTER TABLE "LiteLLM_DailyEndUserSpend" ADD COLUMN IF NOT EXISTS "timed_completion_tokens" BIGINT;
ALTER TABLE "LiteLLM_DailyAgentSpend" ADD COLUMN IF NOT EXISTS "timed_completion_tokens" BIGINT;
ALTER TABLE "LiteLLM_DailyTeamSpend" ADD COLUMN IF NOT EXISTS "timed_completion_tokens" BIGINT;
ALTER TABLE "LiteLLM_DailyTagSpend" ADD COLUMN IF NOT EXISTS "timed_completion_tokens" BIGINT;

View file

@ -870,6 +870,7 @@ model LiteLLM_DailyUserSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -939,6 +940,7 @@ model LiteLLM_DailyOrganizationSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -977,6 +979,7 @@ model LiteLLM_DailyEndUserSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([end_user_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint])
@ -1014,6 +1017,7 @@ model LiteLLM_DailyAgentSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([agent_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint])
@ -1051,6 +1055,7 @@ model LiteLLM_DailyTeamSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
ptu_flat_cost Float @default(0.0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -1091,6 +1096,7 @@ model LiteLLM_DailyTagSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View file

@ -348,6 +348,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
"LiteLLM_WorkflowRun",
"LiteLLM_WorkflowEvent",
"LiteLLM_WorkflowMessage",
"LiteLLM_BackgroundInteractionSettlement",
"LiteLLM_Lens",
"LiteLLM_LensRun",
"LiteLLM_LensWorker",

View file

@ -4016,6 +4016,13 @@
},
"metrics": {
"$ref": "#/components/schemas/SpendMetrics"
},
"provider_breakdown": {
"additionalProperties": {
"$ref": "#/components/schemas/ProviderThroughputMetrics"
},
"title": "Provider Breakdown",
"type": "object"
}
},
"required": [
@ -4343,6 +4350,44 @@
"title": "PatchAgentRequest",
"type": "object"
},
"ProviderThroughputMetrics": {
"properties": {
"output_tokens_per_second": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Output Tokens Per Second"
},
"timed_completion_tokens": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Timed Completion Tokens"
},
"timed_requests": {
"default": 0,
"title": "Timed Requests",
"type": "integer"
},
"total_response_time_ms": {
"default": 0,
"title": "Total Response Time Ms",
"type": "integer"
}
},
"title": "ProviderThroughputMetrics",
"type": "object"
},
"SpendAnalyticsPaginatedResponse": {
"properties": {
"metadata": {

View file

@ -5705,6 +5705,7 @@ class BaseDailySpendTransaction(TypedDict):
failed_requests: int
total_response_time_ms: NotRequired[int] # writable-ok: the rollup queue accumulates into this key in place
timed_requests: NotRequired[int] # writable-ok: the rollup queue accumulates into this key in place
timed_completion_tokens: NotRequired[int | None]
class DailyTeamSpendTransaction(BaseDailySpendTransaction):

View file

@ -91,6 +91,7 @@ _COUNTER_COLUMNS: Final = (
"compression_saved_tokens",
"total_response_time_ms",
"timed_requests",
"timed_completion_tokens",
)
_SPEND_COLUMNS: Final = (
"spend",
@ -124,6 +125,23 @@ def _as_float(value: object) -> float:
return float(value) if isinstance(value, (int, float)) else 0.0
def _counter_total(column: str, group: Sequence[SpendRow]) -> int | None:
values: Final = tuple(row.get(column) for row in group)
has_unknown_timed_tokens: Final = column == "timed_completion_tokens" and any(
row.get("timed_completion_tokens") is None and _as_int(row.get("timed_requests")) > 0 for row in group
)
if has_unknown_timed_tokens:
return None
return sum(_as_int(value) for value in values)
def _counter_value(column: str, transaction: SpendRow) -> int | None:
value: Final = transaction.get(column)
if column == "timed_completion_tokens" and value is None and _as_int(transaction.get("timed_requests")) > 0:
return None
return _as_int(value)
def conflict_key(table: DailySpendTable, transaction: SpendRow) -> tuple[str, ...]:
"""The tuple the database arbitrates the upsert on, normalized free of NULLs."""
return tuple(_as_text(transaction.get(column)) for column in (table.entity_id_column, *_KEY_COLUMNS))
@ -134,7 +152,7 @@ def _merge(group: Sequence[SpendRow]) -> SpendRow:
return group[0]
return {
**group[0],
**{column: sum(_as_int(row.get(column)) for row in group) for column in _COUNTER_COLUMNS},
**{column: _counter_total(column, group) for column in _COUNTER_COLUMNS},
**{column: sum(_as_float(row.get(column)) for row in group) for column in _SPEND_COLUMNS},
}
@ -165,7 +183,7 @@ def _row_params(
str(uuid.uuid4()),
*key,
None if transaction.get("model_group") is None else _as_text(transaction.get("model_group")),
*(_as_int(transaction.get(column)) for column in _COUNTER_COLUMNS),
*(_counter_value(column, transaction) for column in _COUNTER_COLUMNS),
*(_as_float(transaction.get(column)) for column in _SPEND_COLUMNS),
*((None if request_id is None else _as_text(request_id),) if table.carries_request_id else ()),
)
@ -199,9 +217,18 @@ def build_bulk_upsert(
+ ", (NOW() AT TIME ZONE 'UTC'))"
for row_index in range(len(batch))
)
increments: Final = ", ".join(
f'"{column}" = {quoted_table}."{column}" + EXCLUDED."{column}"'
for column in (*_COUNTER_COLUMNS, *_SPEND_COLUMNS)
regular_increment_columns: Final = tuple(
column for column in (*_COUNTER_COLUMNS, *_SPEND_COLUMNS) if column != "timed_completion_tokens"
)
regular_increments: Final = ", ".join(
f'"{column}" = {quoted_table}."{column}" + EXCLUDED."{column}"' for column in regular_increment_columns
)
timed_completion_tokens_increment: Final = (
f'"timed_completion_tokens" = CASE '
f'WHEN ({quoted_table}."timed_requests" > 0 AND {quoted_table}."timed_completion_tokens" IS NULL) '
'OR (EXCLUDED."timed_requests" > 0 AND EXCLUDED."timed_completion_tokens" IS NULL) THEN NULL '
f'ELSE COALESCE({quoted_table}."timed_completion_tokens", 0) '
'+ COALESCE(EXCLUDED."timed_completion_tokens", 0) END'
)
# request_id names one arbitrary contributing request, so an entry carrying none must
# not blank out the one already recorded.
@ -214,7 +241,7 @@ def build_bulk_upsert(
f'INSERT INTO {quoted_table} ({_quoted(columns)}, "updated_at")\n'
f"VALUES {rows}\n"
f"ON CONFLICT ({_quoted((table.entity_id_column, *_KEY_COLUMNS))}) DO UPDATE SET\n"
f" {increments}{request_id_update},\n"
f" {regular_increments}, {timed_completion_tokens_increment}{request_id_update},\n"
f" \"updated_at\" = (NOW() AT TIME ZONE 'UTC')"
)
return sql, tuple(value for key, transaction in batch for value in _row_params(table, key, transaction))

View file

@ -2820,6 +2820,7 @@ class DBSpendUpdateWriter:
autorouter_savings_spend=0.0 if is_internal_call else savings_spend.autorouter,
total_response_time_ms=timed_duration_ms or 0,
timed_requests=0 if timed_duration_ms is None else 1,
timed_completion_tokens=0 if timed_duration_ms is None else cast(int, payload["completion_tokens"]),
)
return daily_transaction
except Exception as e:

View file

@ -1,6 +1,8 @@
import asyncio
from collections.abc import Coroutine
from collections.abc import Coroutine, Iterator
from copy import deepcopy
from functools import reduce
from itertools import groupby
from typing import Final
from litellm._logging import verbose_proxy_logger
@ -13,6 +15,62 @@ from litellm.proxy.db.db_transaction_queue.base_update_queue import (
from litellm.types.services import ServiceTypes
def _daily_spend_updates(
updates: list[dict[str, BaseDailySpendTransaction]],
) -> Iterator[tuple[str, BaseDailySpendTransaction]]:
for update in updates:
yield from update.items()
def _sum_timed_completion_tokens(
left_tokens: int | None,
left_requests: int,
right_tokens: int | None,
right_requests: int,
) -> int | None:
if (left_tokens is None and left_requests > 0) or (right_tokens is None and right_requests > 0):
return None
return (left_tokens or 0) + (right_tokens or 0)
def _merge_daily_spend_transactions(
existing: BaseDailySpendTransaction,
payload: BaseDailySpendTransaction,
) -> BaseDailySpendTransaction:
return {
**existing,
"spend": existing["spend"] + payload["spend"],
"prompt_tokens": existing["prompt_tokens"] + payload["prompt_tokens"],
"completion_tokens": existing["completion_tokens"] + payload["completion_tokens"],
"api_requests": existing["api_requests"] + payload["api_requests"],
"successful_requests": existing["successful_requests"] + payload["successful_requests"],
"failed_requests": existing["failed_requests"] + payload["failed_requests"],
"cache_read_input_tokens": (existing.get("cache_read_input_tokens", 0) or 0)
+ (payload.get("cache_read_input_tokens", 0) or 0),
"cache_creation_input_tokens": (existing.get("cache_creation_input_tokens", 0) or 0)
+ (payload.get("cache_creation_input_tokens", 0) or 0),
"compression_saved_tokens": (existing.get("compression_saved_tokens", 0) or 0)
+ (payload.get("compression_saved_tokens", 0) or 0),
"compression_savings_spend": (existing.get("compression_savings_spend", 0) or 0)
+ (payload.get("compression_savings_spend", 0) or 0),
"prompt_caching_savings_spend": (existing.get("prompt_caching_savings_spend", 0) or 0)
+ (payload.get("prompt_caching_savings_spend", 0) or 0),
"gateway_injected_caching_savings_spend": (existing.get("gateway_injected_caching_savings_spend", 0) or 0)
+ (payload.get("gateway_injected_caching_savings_spend", 0) or 0),
"autorouter_savings_spend": (existing.get("autorouter_savings_spend", 0) or 0)
+ (payload.get("autorouter_savings_spend", 0) or 0),
"total_response_time_ms": (existing.get("total_response_time_ms", 0) or 0)
+ (payload.get("total_response_time_ms", 0) or 0),
"timed_requests": (existing.get("timed_requests", 0) or 0) + (payload.get("timed_requests", 0) or 0),
"timed_completion_tokens": _sum_timed_completion_tokens(
existing.get("timed_completion_tokens"),
existing.get("timed_requests", 0) or 0,
payload.get("timed_completion_tokens"),
payload.get("timed_requests", 0) or 0,
),
}
class DailySpendUpdateQueue(BaseUpdateQueue):
"""
In memory buffer for daily spend updates that should be committed to the database
@ -113,58 +171,14 @@ class DailySpendUpdateQueue(BaseUpdateQueue):
updates: list[dict[str, BaseDailySpendTransaction]],
) -> dict[str, BaseDailySpendTransaction]:
"""Aggregate updates by daily_transaction_key."""
aggregated_daily_spend_update_transactions: Final[dict[str, BaseDailySpendTransaction]] = {}
for _update in updates:
for _key, payload in _update.items():
if _key in aggregated_daily_spend_update_transactions:
daily_transaction = aggregated_daily_spend_update_transactions[_key]
daily_transaction["spend"] += payload["spend"]
daily_transaction["prompt_tokens"] += payload["prompt_tokens"]
daily_transaction["completion_tokens"] += payload["completion_tokens"]
daily_transaction["api_requests"] += payload["api_requests"]
daily_transaction["successful_requests"] += payload["successful_requests"]
daily_transaction["failed_requests"] += payload["failed_requests"]
# Add optional metrics cache_read_input_tokens and cache_creation_input_tokens
daily_transaction["cache_read_input_tokens"] = (
payload.get("cache_read_input_tokens", 0) or 0
) + daily_transaction.get("cache_read_input_tokens", 0)
daily_transaction["cache_creation_input_tokens"] = (
payload.get("cache_creation_input_tokens", 0) or 0
) + daily_transaction.get("cache_creation_input_tokens", 0)
daily_transaction["compression_saved_tokens"] = (
payload.get("compression_saved_tokens", 0) or 0
) + daily_transaction.get("compression_saved_tokens", 0)
daily_transaction["compression_savings_spend"] = (
payload.get("compression_savings_spend", 0) or 0
) + daily_transaction.get("compression_savings_spend", 0)
daily_transaction["prompt_caching_savings_spend"] = (
payload.get("prompt_caching_savings_spend", 0) or 0
) + daily_transaction.get("prompt_caching_savings_spend", 0)
daily_transaction["gateway_injected_caching_savings_spend"] = (
payload.get("gateway_injected_caching_savings_spend", 0) or 0
) + daily_transaction.get("gateway_injected_caching_savings_spend", 0)
daily_transaction["autorouter_savings_spend"] = (
payload.get("autorouter_savings_spend", 0) or 0
) + daily_transaction.get("autorouter_savings_spend", 0)
daily_transaction["total_response_time_ms"] = (
payload.get("total_response_time_ms", 0) or 0
) + daily_transaction.get("total_response_time_ms", 0)
daily_transaction["timed_requests"] = (
payload.get("timed_requests", 0) or 0
) + daily_transaction.get("timed_requests", 0)
else:
aggregated_daily_spend_update_transactions[_key] = deepcopy(payload)
return aggregated_daily_spend_update_transactions
ordered_updates: Final = sorted(_daily_spend_updates(updates), key=lambda update: update[0])
return {
key: reduce(
_merge_daily_spend_transactions,
(deepcopy(payload) for _, payload in grouped_updates),
)
for key, grouped_updates in groupby(ordered_updates, key=lambda update: update[0])
}
async def _emit_new_item_added_to_queue_event(
self,

View file

@ -31,6 +31,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
KeyMetadata,
KeyMetricWithMetadata,
MetricWithMetadata,
ProviderThroughputMetrics,
SpendAnalyticsPaginatedResponse,
SpendMetrics,
)
@ -160,6 +161,9 @@ class DailySpendRecord(Protocol):
@property
def timed_requests(self) -> int: ...
@property
def timed_completion_tokens(self) -> int | None: ...
class _KeyMetadataDict(TypedDict, total=False):
key_alias: ReadOnly[str | None]
@ -236,6 +240,56 @@ def update_metrics(existing_metrics: SpendMetrics, record: DailySpendRecord) ->
return existing_metrics
def _provider_throughput(
timed_completion_tokens: int | None,
total_response_time_ms: int,
timed_requests: int,
) -> ProviderThroughputMetrics:
output_tokens_per_second: Final = (
timed_completion_tokens * 1000 / total_response_time_ms
if timed_completion_tokens is not None and timed_requests > 0 and total_response_time_ms > 0
else None
)
return ProviderThroughputMetrics(
timed_completion_tokens=timed_completion_tokens,
total_response_time_ms=total_response_time_ms,
timed_requests=timed_requests,
output_tokens_per_second=output_tokens_per_second,
)
def _combined_timed_completion_tokens(
existing: ProviderThroughputMetrics | None,
current: int | None,
) -> int | None:
if existing is None:
return current
if existing.timed_completion_tokens is None or current is None:
return None
return existing.timed_completion_tokens + current
def _update_provider_throughput(
target: MetricWithMetadata,
provider: str,
record: DailySpendRecord,
) -> None:
existing: Final = target.provider_breakdown.get(provider)
timed_completion_tokens: Final = _combined_timed_completion_tokens(
existing,
record.timed_completion_tokens,
)
target.provider_breakdown = {
**target.provider_breakdown,
provider: _provider_throughput(
timed_completion_tokens=timed_completion_tokens,
total_response_time_ms=(existing.total_response_time_ms if existing is not None else 0)
+ (record.total_response_time_ms or 0),
timed_requests=(existing.timed_requests if existing is not None else 0) + (record.timed_requests or 0),
),
}
def _is_user_agent_tag(tag: str | None) -> bool:
"""Determine whether a tag should be treated as a User-Agent tag."""
if not tag:
@ -312,6 +366,12 @@ def update_breakdown_metrics(
breakdown.models[model_key].metrics = update_metrics(breakdown.models[model_key].metrics, record)
if not is_ptu_sentinel:
_update_provider_throughput(
breakdown.models[model_key],
record.custom_llm_provider or "unknown",
record,
)
# Update API key breakdown for this model
if record.api_key not in breakdown.models[model_key].api_key_breakdown:
breakdown.models[model_key].api_key_breakdown[record.api_key] = KeyMetricWithMetadata(
@ -336,6 +396,12 @@ def update_breakdown_metrics(
)
if not is_ptu_sentinel:
_update_provider_throughput(
breakdown.model_groups[model_group_key],
record.custom_llm_provider or "unknown",
record,
)
# Update API key breakdown for this model
if record.api_key not in breakdown.model_groups[model_group_key].api_key_breakdown:
breakdown.model_groups[model_group_key].api_key_breakdown[record.api_key] = KeyMetricWithMetadata(
@ -697,8 +763,10 @@ _API_KEY_ROLLED_UP_BIT: Final = 32 # 0b0100000
_GROUP_DATE_API_KEY: Final = 31 # 0b0011111
_GROUP_DATE_MODEL: Final = 47 # 0b0101111
_GROUP_DATE_MODEL_API_KEY: Final = 15 # 0b0001111
_GROUP_DATE_MODEL_PROVIDER: Final = 43 # 0b0101011
_GROUP_DATE_MODEL_GROUP: Final = 55 # 0b0110111
_GROUP_DATE_MODEL_GROUP_API_KEY: Final = 23 # 0b0010111
_GROUP_DATE_MODEL_GROUP_PROVIDER: Final = 51 # 0b0110011
_GROUP_DATE_PROVIDER: Final = 59 # 0b0111011
_GROUP_DATE_PROVIDER_API_KEY: Final = 27 # 0b0011011
_GROUP_DATE_MCP: Final = 61 # 0b0111101
@ -779,6 +847,35 @@ def _aggregate_grouping_sets_records_sync(
metrics=metrics, metadata=_key_metadata(api_key_metadata, api_key)
)
def assign_provider_breakdown(
target: dict[str, MetricWithMetadata],
parent_key: str,
provider: str,
record: GroupingSetsRow,
) -> None:
parent: Final = target.get(parent_key)
if parent is None:
target[parent_key] = MetricWithMetadata(
metrics=SpendMetrics(),
metadata={},
provider_breakdown={
provider: _provider_throughput(
record.timed_completion_tokens,
record.total_response_time_ms or 0,
record.timed_requests or 0,
)
},
)
return
parent.provider_breakdown = {
**parent.provider_breakdown,
provider: _provider_throughput(
record.timed_completion_tokens,
record.total_response_time_ms or 0,
record.timed_requests or 0,
),
}
for record in records:
level = record.group_level
metrics = _record_to_spend_metrics(record)
@ -806,6 +903,14 @@ def _aggregate_grouping_sets_records_sync(
elif level == _GROUP_DATE_MODEL_API_KEY:
if record.model and record.api_key and not is_ptu_sentinel:
assign_api_key_breakdown(breakdown.models, record.model, record.api_key, metrics)
elif level == _GROUP_DATE_MODEL_PROVIDER:
if record.model:
assign_provider_breakdown(
breakdown.models,
record.model,
record.custom_llm_provider or "unknown",
record,
)
elif level == _GROUP_DATE_MODEL_GROUP:
if record.model_group:
assign_metric_with_metadata(breakdown.model_groups, record.model_group, metrics)
@ -817,6 +922,14 @@ def _aggregate_grouping_sets_records_sync(
record.api_key,
metrics,
)
elif level == _GROUP_DATE_MODEL_GROUP_PROVIDER:
if record.model_group:
assign_provider_breakdown(
breakdown.model_groups,
record.model_group,
record.custom_llm_provider or "unknown",
record,
)
elif level == _GROUP_DATE_PROVIDER:
# Only PTU sentinel rows carry ptu_flat_cost and they have no provider, so at
# this level the sentinel's cost would land under "unknown". Withholding the

View file

@ -870,6 +870,7 @@ model LiteLLM_DailyUserSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -939,6 +940,7 @@ model LiteLLM_DailyOrganizationSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -977,6 +979,7 @@ model LiteLLM_DailyEndUserSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([end_user_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint])
@ -1014,6 +1017,7 @@ model LiteLLM_DailyAgentSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([agent_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint])
@ -1051,6 +1055,7 @@ model LiteLLM_DailyTeamSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
ptu_flat_cost Float @default(0.0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -1091,6 +1096,7 @@ model LiteLLM_DailyTagSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View file

@ -129,7 +129,13 @@ def _rollup_metric_select(table: DailyActivityTable) -> str:
SUM(successful_requests)::bigint AS successful_requests,
SUM(failed_requests)::bigint AS failed_requests,
SUM(total_response_time_ms)::bigint AS total_response_time_ms,
SUM(timed_requests)::bigint AS timed_requests"""
SUM(timed_requests)::bigint AS timed_requests,
CASE
WHEN COUNT(*) FILTER (
WHERE timed_requests > 0 AND timed_completion_tokens IS NULL
) > 0 THEN NULL::bigint
ELSE COALESCE(SUM(timed_completion_tokens), 0)::bigint
END AS timed_completion_tokens"""
def _validate_api_key_limit(api_key_limit: int) -> None:
@ -177,7 +183,9 @@ def build_aggregated_sql(scope: DailyActivityScope, *, api_key_limit: int) -> Sq
GROUP BY GROUPING SETS (
(date),
(date, model),
(date, model, custom_llm_provider),
(date, {_MODEL_GROUP_EXPR}),
(date, {_MODEL_GROUP_EXPR}, custom_llm_provider),
(date, custom_llm_provider),
(date, mcp_namespaced_tool_name),
(date, endpoint),

View file

@ -56,10 +56,18 @@ class KeyMetricWithMetadata(MetricBase):
metadata: KeyMetadata = Field(default_factory=KeyMetadata)
class ProviderThroughputMetrics(BaseModel):
timed_completion_tokens: int | None = Field(default=None)
total_response_time_ms: int = Field(default=0)
timed_requests: int = Field(default=0)
output_tokens_per_second: float | None = Field(default=None)
class MetricWithMetadata(MetricBase):
metadata: dict[str, Any] = Field(default_factory=dict)
# API key breakdown for this metric (e.g., which API keys are using this MCP server)
api_key_breakdown: dict[str, KeyMetricWithMetadata] = Field(default_factory=dict) # api_key -> {metrics, metadata}
provider_breakdown: dict[str, ProviderThroughputMetrics] = Field(default_factory=dict)
class BreakdownMetrics(BaseModel):

View file

@ -124,6 +124,7 @@ class RollupMetricsRow:
failed_requests: int | None
total_response_time_ms: int | None
timed_requests: int | None
timed_completion_tokens: int | None
@dataclass(frozen=True, slots=True)
@ -184,6 +185,7 @@ class DailyActivityRow(Protocol):
failed_requests: int
total_response_time_ms: int
timed_requests: int
timed_completion_tokens: int | None
@dataclass(frozen=True, slots=True)

View file

@ -870,6 +870,7 @@ model LiteLLM_DailyUserSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -939,6 +940,7 @@ model LiteLLM_DailyOrganizationSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -977,6 +979,7 @@ model LiteLLM_DailyEndUserSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([end_user_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint])
@ -1014,6 +1017,7 @@ model LiteLLM_DailyAgentSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([agent_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint])
@ -1051,6 +1055,7 @@ model LiteLLM_DailyTeamSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
ptu_flat_cost Float @default(0.0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -1091,6 +1096,7 @@ model LiteLLM_DailyTagSpend {
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
timed_completion_tokens BigInt?
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View file

@ -40,6 +40,7 @@ async def test_add_single_update(daily_spend_update_queue):
"spend": 10.0,
"prompt_tokens": 100,
"completion_tokens": 50,
"timed_completion_tokens": 50,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
@ -73,6 +74,7 @@ async def test_add_multiple_updates(daily_spend_update_queue):
"spend": 5.0,
"prompt_tokens": 200,
"completion_tokens": 30,
"timed_completion_tokens": 0,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
@ -105,9 +107,7 @@ async def test_add_multiple_updates(daily_spend_update_queue):
@pytest.mark.asyncio
async def test_aggregated_daily_spend_update_empty(daily_spend_update_queue):
"""Test aggregating updates from an empty queue"""
result = (
await daily_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
)
result = await daily_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
assert result == {}
@ -127,9 +127,7 @@ async def test_get_aggregated_daily_spend_update_transactions_single_key():
updates = [{test_key: test_transaction}]
# Test aggregation
result = DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(
updates
)
result = DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(updates)
assert len(result) == 1
assert test_key in result
@ -162,9 +160,7 @@ async def test_get_aggregated_daily_spend_update_transactions_multiple_keys():
updates = [{test_key1: test_transaction1}, {test_key2: test_transaction2}]
# Test aggregation
result = DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(
updates
)
result = DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(updates)
assert len(result) == 2
assert test_key1 in result
@ -181,6 +177,7 @@ async def test_get_aggregated_daily_spend_update_transactions_same_key():
"spend": 10.0,
"prompt_tokens": 100,
"completion_tokens": 50,
"timed_completion_tokens": 50,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
@ -190,6 +187,7 @@ async def test_get_aggregated_daily_spend_update_transactions_same_key():
"spend": 5.0,
"prompt_tokens": 200,
"completion_tokens": 30,
"timed_completion_tokens": 0,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
@ -199,6 +197,7 @@ async def test_get_aggregated_daily_spend_update_transactions_same_key():
"spend": 15.0, # 10 + 5
"prompt_tokens": 300, # 100 + 200
"completion_tokens": 80, # 50 + 30
"timed_completion_tokens": 50,
"api_requests": 2, # 1 + 1
"successful_requests": 2, # 1 + 1
"failed_requests": 0, # 0 + 0
@ -216,9 +215,7 @@ async def test_get_aggregated_daily_spend_update_transactions_same_key():
updates = [{test_key: test_transaction1}, {test_key: test_transaction2}]
# Test aggregation
result = DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(
updates
)
result = DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(updates)
assert len(result) == 1
assert test_key in result
@ -235,6 +232,7 @@ async def test_flush_and_get_aggregated_daily_spend_update_transactions(
"spend": 10.0,
"prompt_tokens": 100,
"completion_tokens": 50,
"timed_completion_tokens": 50,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
@ -244,6 +242,7 @@ async def test_flush_and_get_aggregated_daily_spend_update_transactions(
"spend": 5.0,
"prompt_tokens": 200,
"completion_tokens": 30,
"timed_completion_tokens": 0,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
@ -253,6 +252,7 @@ async def test_flush_and_get_aggregated_daily_spend_update_transactions(
"spend": 15.0, # 10 + 5
"prompt_tokens": 300, # 100 + 200
"completion_tokens": 80, # 50 + 30
"timed_completion_tokens": 50,
"api_requests": 2, # 1 + 1
"successful_requests": 2, # 1 + 1
"failed_requests": 0, # 0 + 0
@ -272,9 +272,7 @@ async def test_flush_and_get_aggregated_daily_spend_update_transactions(
await daily_spend_update_queue.add_update({test_key: test_transaction2})
# Flush and get aggregated transactions
result = (
await daily_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
)
result = await daily_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
assert len(result) == 1
assert test_key in result
@ -282,9 +280,7 @@ async def test_flush_and_get_aggregated_daily_spend_update_transactions(
@pytest.mark.asyncio
async def test_queue_max_size_triggers_aggregation(
monkeypatch, daily_spend_update_queue
):
async def test_queue_max_size_triggers_aggregation(monkeypatch, daily_spend_update_queue):
"""Test that reaching MAX_SIZE_IN_MEMORY_QUEUE triggers aggregation"""
# Override MAX_SIZE_IN_MEMORY_QUEUE for testing
litellm._turn_on_debug()
@ -308,9 +304,7 @@ async def test_queue_max_size_triggers_aggregation(
assert daily_spend_update_queue.update_queue.qsize() == 1
# Verify the aggregated values
result = (
await daily_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
)
result = await daily_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
assert result[test_key]["spend"] == 6.0
assert result[test_key]["prompt_tokens"] == 600
assert result[test_key]["completion_tokens"] == 300
@ -427,9 +421,7 @@ async def test_cache_token_fields_aggregation(daily_spend_update_queue):
@pytest.mark.asyncio
async def test_queue_size_reduction_with_large_volume(
monkeypatch, daily_spend_update_queue
):
async def test_queue_size_reduction_with_large_volume(monkeypatch, daily_spend_update_queue):
"""Test that queue size is actually reduced when dealing with many items"""
# Set a smaller MAX_SIZE for testing
monkeypatch.setattr(daily_spend_update_queue, "MAX_SIZE_IN_MEMORY_QUEUE", 10)
@ -470,9 +462,7 @@ async def test_queue_size_reduction_with_large_volume(
assert daily_spend_update_queue.update_queue.qsize() <= 10
# Verify total costs are correct
result = (
await daily_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
)
result = await daily_spend_update_queue.flush_and_get_aggregated_daily_spend_update_transactions()
print("RESULT", json.dumps(result, indent=4))
assert result[user1_key]["spend"] == 200 * 0.5 # 10.0
@ -545,6 +535,7 @@ async def test_every_optional_daily_metric_aggregates(daily_spend_update_queue):
paths, so the driver reads as zero on the dashboard however much it saved.
"""
test_key = "user1_2023-01-01_key123_claude-haiku-4-5_anthropic"
def _numeric(annotation):
# additive metrics may be declared NotRequired[float] for rows queued by a pod
# running the previous release, so unwrap before matching
@ -584,7 +575,15 @@ async def test_optional_metric_missing_from_an_older_payload_still_aggregates(
await daily_spend_update_queue.add_update({test_key: dict(base)})
await daily_spend_update_queue.add_update(
{test_key: {**base, "autorouter_savings_spend": 0.25, "total_response_time_ms": 900, "timed_requests": 1}}
{
test_key: {
**base,
"autorouter_savings_spend": 0.25,
"total_response_time_ms": 900,
"timed_requests": 1,
"timed_completion_tokens": 5,
}
}
)
await daily_spend_update_queue.aggregate_queue_updates()
updates = await daily_spend_update_queue.flush_all_updates_from_in_memory_queue()
@ -592,3 +591,22 @@ async def test_optional_metric_missing_from_an_older_payload_still_aggregates(
assert updates[0][test_key]["autorouter_savings_spend"] == pytest.approx(0.25)
assert updates[0][test_key]["total_response_time_ms"] == 900
assert updates[0][test_key]["timed_requests"] == 1
assert updates[0][test_key]["timed_completion_tokens"] == 5
def test_legacy_timed_request_keeps_timed_tokens_unknown():
key = "user1_2023-01-01_key123_gpt-4o_openai"
base = {
"spend": 1.0,
"prompt_tokens": 10,
"completion_tokens": 5,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
"timed_requests": 1,
}
updates = [{key: base}, {key: {**base, "timed_completion_tokens": 5}}]
aggregated = DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(updates)
assert aggregated[key]["timed_completion_tokens"] is None

View file

@ -8,6 +8,7 @@ import pytest
from litellm.proxy.db.daily_spend_bulk_upsert import (
DAILY_SPEND_TABLES,
_counter_value,
build_bulk_upsert,
conflict_key,
merge_by_conflict_key,
@ -34,6 +35,7 @@ def tag_txn(**overrides):
"endpoint": "/chat/completions",
"prompt_tokens": 10,
"completion_tokens": 20,
"timed_completion_tokens": 20,
"spend": 0.25,
"api_requests": 1,
"successful_requests": 1,
@ -71,6 +73,31 @@ def test_null_and_empty_provider_merge_into_one_row(order):
assert folded["api_requests"] == 4
def test_untimed_legacy_rows_do_not_hide_known_timed_tokens_when_rows_merge():
legacy = tag_txn()
del legacy["timed_completion_tokens"]
merged = merge_by_conflict_key(TAG_TABLE, (legacy, tag_txn(timed_completion_tokens=7)))
assert merged[0][1]["timed_completion_tokens"] == 7
def test_legacy_timed_requests_keep_timed_tokens_unknown_when_rows_merge():
legacy = tag_txn(timed_requests=1)
del legacy["timed_completion_tokens"]
merged = merge_by_conflict_key(TAG_TABLE, (legacy, tag_txn(timed_completion_tokens=7)))
assert merged[0][1]["timed_completion_tokens"] is None
def test_legacy_timed_request_serializes_unknown_token_total():
legacy = tag_txn(timed_requests=1)
del legacy["timed_completion_tokens"]
assert _counter_value("timed_completion_tokens", legacy) is None
def test_distinct_keys_are_not_merged_and_are_ordered_deterministically():
unordered = (tag_txn(tag="z-team"), tag_txn(tag="a-team"), tag_txn(tag="m-team"))
@ -87,10 +114,10 @@ def test_one_statement_carries_every_row_in_the_batch():
assert sql.count("INSERT INTO") == 1
assert len(re.findall(r"ON CONFLICT", sql)) == 1
# 25 bound columns per row plus the inlined updated_at, so the row count is what
# 26 bound columns per row plus the inlined updated_at, so the row count is what
# separates one multi-row statement from a hundred single-row ones.
assert len(params) == 100 * 25
assert "$2500::text" in sql
assert len(params) == 100 * 26
assert "$2600::text" in sql
assert sql.count("(NOW() AT TIME ZONE 'UTC')") == 100 + 1
@ -125,9 +152,7 @@ def test_counters_increment_rather_than_overwrite(column):
def test_request_id_is_preserved_when_a_later_batch_carries_none():
sql, params = build_bulk_upsert(
TAG_TABLE, merge_by_conflict_key(TAG_TABLE, (tag_txn(request_id=None),))
)
sql, params = build_bulk_upsert(TAG_TABLE, merge_by_conflict_key(TAG_TABLE, (tag_txn(request_id=None),)))
assert '"request_id" = COALESCE(EXCLUDED."request_id", "LiteLLM_DailyTagSpend"."request_id")' in sql
assert None in params

View file

@ -103,11 +103,21 @@ async def test_update_database_attributes_router_rejected_failure_to_model_group
)
with (
patch("litellm.proxy.proxy_server.disable_spend_logs", True), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch("litellm.proxy.proxy_server.litellm_proxy_budget_name", "test-budget"), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch("litellm.proxy.proxy_server.llm_router", llm_router), # test-quality-ok: get_llm_router reads this proxy_server module global at call time; no injection seam
patch(
"litellm.proxy.proxy_server.disable_spend_logs", True
), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch(
"litellm.proxy.proxy_server.prisma_client", MagicMock()
), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch(
"litellm.proxy.proxy_server.user_api_key_cache", MagicMock()
), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch(
"litellm.proxy.proxy_server.litellm_proxy_budget_name", "test-budget"
), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch(
"litellm.proxy.proxy_server.llm_router", llm_router
), # test-quality-ok: get_llm_router reads this proxy_server module global at call time; no injection seam
):
await db_writer.update_database(
token="test-token",
@ -320,7 +330,9 @@ async def test_a_routed_request_reaches_the_auto_router_rollup_whether_or_not_sp
}
with (
patch("litellm.proxy.proxy_server.disable_spend_logs", disable_spend_logs), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch(
"litellm.proxy.proxy_server.disable_spend_logs", disable_spend_logs
), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.litellm_proxy_budget_name", "test-budget"),
patch(
@ -2912,17 +2924,22 @@ async def test_daily_transaction_carries_compression_saved_tokens():
@pytest.mark.asyncio
@pytest.mark.parametrize("estimate, recorded_savings, expected", [
pytest.param(None, None, -0.005, id="plain-classifier-cost"),
pytest.param({"version": 1, "status": "unknown"}, None, 0.0, id="unknown"),
pytest.param({"version": 2, "status": "unknown"}, None, 0.0, id="unknown-v2"),
pytest.param({"version": 1, "status": "unknown"}, -0.003, 0.0, id="unknown-stale-value"),
pytest.param({"version": 0, "status": "estimated"}, -0.003, 0.0, id="unsupported-version"),
pytest.param({"version": 1, "status": "estimated"}, -0.003, -0.003, id="estimated"),
pytest.param(None, -0.003, -0.003, id="legacy"),
])
@pytest.mark.parametrize(
"estimate, recorded_savings, expected",
[
pytest.param(None, None, -0.005, id="plain-classifier-cost"),
pytest.param({"version": 1, "status": "unknown"}, None, 0.0, id="unknown"),
pytest.param({"version": 2, "status": "unknown"}, None, 0.0, id="unknown-v2"),
pytest.param({"version": 1, "status": "unknown"}, -0.003, 0.0, id="unknown-stale-value"),
pytest.param({"version": 0, "status": "estimated"}, -0.003, 0.0, id="unsupported-version"),
pytest.param({"version": 1, "status": "estimated"}, -0.003, -0.003, id="estimated"),
pytest.param(None, -0.003, -0.003, id="legacy"),
],
)
async def test_daily_transaction_compression_saved_tokens_zero_when_absent(
estimate: dict[str, object] | None, recorded_savings: float | None, expected: float,
estimate: dict[str, object] | None,
recorded_savings: float | None,
expected: float,
) -> None:
"""Requests without any compression metadata produce a zero count."""
writer = DBSpendUpdateWriter()
@ -2941,12 +2958,14 @@ async def test_daily_transaction_compression_saved_tokens_zero_when_absent(
"prompt_tokens": 100,
"completion_tokens": 10,
"spend": 0.01,
"metadata": json.dumps({
"usage_object": {"prompt_tokens": 100, "completion_tokens": 10},
"routing_decision": {"savings_baseline_model": "anthropic/claude-sonnet-5", "classifier_cost": 0.005},
"autorouter_savings": recorded_savings,
"autorouter_savings_estimate": estimate,
}),
"metadata": json.dumps(
{
"usage_object": {"prompt_tokens": 100, "completion_tokens": 10},
"routing_decision": {"savings_baseline_model": "anthropic/claude-sonnet-5", "classifier_cost": 0.005},
"autorouter_savings": recorded_savings,
"autorouter_savings_estimate": estimate,
}
),
}
transaction = await writer._common_add_spend_log_transaction_to_daily_transaction(
@ -3439,9 +3458,7 @@ async def test_failed_per_entity_increment_from_redis_restores_only_what_may_sti
)
mock_redis_update_buffer.restore_transactions_to_redis.assert_awaited_once()
restored = mock_redis_update_buffer.restore_transactions_to_redis.call_args.kwargs[
"db_spend_update_transactions"
]
restored = mock_redis_update_buffer.restore_transactions_to_redis.call_args.kwargs["db_spend_update_transactions"]
assert restored["user_list_transactions"] is None
assert restored["team_list_transactions"] == {"team-1": 1.5}
assert restored["key_list_transactions"] == ({"key-1": 1.5} if safe_to_resend else None)
@ -3758,6 +3775,7 @@ async def test_daily_transaction_rolls_up_response_time_for_successful_requests(
assert transaction is not None
assert transaction["total_response_time_ms"] == request_duration_ms
assert transaction["timed_requests"] == 1
assert transaction["timed_completion_tokens"] == 5
@pytest.mark.asyncio
@ -3790,6 +3808,7 @@ async def test_daily_transaction_excludes_untimed_requests_from_response_time(
assert transaction is not None
assert transaction["total_response_time_ms"] == 0
assert transaction["timed_requests"] == 0
assert transaction["timed_completion_tokens"] == 0
def _deadlock_error():

View file

@ -366,6 +366,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
"autorouter_savings_spend": 0.0,
"total_response_time_ms": 0,
"timed_requests": 0,
"timed_completion_tokens": 0,
"failed_requests": 0,
}
mock_rows = [
@ -950,6 +951,7 @@ def test_update_breakdown_metrics_includes_user_email():
autorouter_savings_spend=0,
total_response_time_ms=0,
timed_requests=0,
timed_completion_tokens=0,
total_tokens=2,
api_requests=1,
successful_requests=1,
@ -1031,6 +1033,7 @@ async def test_tag_daily_activity_metadata_totals_not_zero():
mock_record_1.autorouter_savings_spend = 0.0
mock_record_1.total_response_time_ms = 18_000
mock_record_1.timed_requests = 9
mock_record_1.timed_completion_tokens = 200
mock_record_1.api_requests = 10
mock_record_1.successful_requests = 9
mock_record_1.failed_requests = 1
@ -1057,6 +1060,7 @@ async def test_tag_daily_activity_metadata_totals_not_zero():
mock_record_2.autorouter_savings_spend = 0.0
mock_record_2.total_response_time_ms = 2_500
mock_record_2.timed_requests = 5
mock_record_2.timed_completion_tokens = 100
mock_record_2.api_requests = 5
mock_record_2.successful_requests = 5
mock_record_2.failed_requests = 0
@ -1129,6 +1133,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
"autorouter_savings_spend": 0.0,
"total_response_time_ms": 0,
"timed_requests": 0,
"timed_completion_tokens": 0,
"failed_requests": 0,
}
mock_rows = [
@ -1225,6 +1230,7 @@ async def test_aggregated_activity_flags_only_keys_that_key_info_can_still_resol
"autorouter_savings_spend": 0.0,
"total_response_time_ms": 0,
"timed_requests": 0,
"timed_completion_tokens": 0,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
@ -1295,6 +1301,7 @@ def _daily_user_spend_record(*, user_id, api_key, spend, model="gpt-4", model_gr
autorouter_savings_spend=0.0,
total_response_time_ms=0,
timed_requests=0,
timed_completion_tokens=0,
api_requests=1,
successful_requests=1,
failed_requests=0,
@ -1444,6 +1451,7 @@ async def test_get_daily_activity_aggregated_empty_result_set():
"autorouter_savings_spend": None,
"total_response_time_ms": None,
"timed_requests": None,
"timed_completion_tokens": None,
"api_requests": None,
"successful_requests": None,
"failed_requests": None,
@ -1492,6 +1500,7 @@ def _no_spend_record():
autorouter_savings_spend=None,
total_response_time_ms=None,
timed_requests=None,
timed_completion_tokens=None,
api_requests=None,
successful_requests=None,
failed_requests=None,
@ -1631,6 +1640,7 @@ def _spend_record(api_key, *, model="gpt-4o-mini-ptu", spend=0.0, ptu_flat_cost=
autorouter_savings_spend=0,
total_response_time_ms=0,
timed_requests=0,
timed_completion_tokens=0,
total_tokens=0,
api_requests=0,
successful_requests=0,
@ -1675,6 +1685,10 @@ def _grouping_row(
endpoint=None,
spend=0.0,
ptu_flat_cost=0.0,
completion_tokens=0,
timed_completion_tokens=0,
total_response_time_ms=0,
timed_requests=0,
):
return GroupingSetsRow(
date="2024-01-01",
@ -1689,7 +1703,8 @@ def _grouping_row(
spend=spend,
ptu_flat_cost=ptu_flat_cost,
prompt_tokens=0,
completion_tokens=0,
completion_tokens=completion_tokens,
timed_completion_tokens=timed_completion_tokens,
cache_read_input_tokens=0,
cache_creation_input_tokens=0,
compression_saved_tokens=0,
@ -1697,8 +1712,8 @@ def _grouping_row(
prompt_caching_savings_spend=0.0,
gateway_injected_caching_savings_spend=0.0,
autorouter_savings_spend=0.0,
total_response_time_ms=0,
timed_requests=0,
total_response_time_ms=total_response_time_ms,
timed_requests=timed_requests,
api_requests=0,
successful_requests=0,
failed_requests=0,
@ -1794,6 +1809,108 @@ def test_grouping_sets_dispatcher_populates_every_breakdown_level(ptu_cost_attri
assert "real-key" in day.breakdown.endpoints["/v1/chat/completions"].api_key_breakdown
def test_grouping_sets_dispatcher_returns_provider_throughput_for_models_and_model_groups():
from litellm.proxy.management_endpoints.common_daily_activity import (
_GROUP_DATE_MODEL_GROUP_PROVIDER,
_GROUP_DATE_MODEL_PROVIDER,
_aggregate_grouping_sets_records_sync,
)
records = [
_grouping_row(
_GROUP_DATE_MODEL_PROVIDER,
model="gpt-4o",
custom_llm_provider="openai",
completion_tokens=1900,
timed_completion_tokens=900,
total_response_time_ms=3000,
timed_requests=3,
),
_grouping_row(
_GROUP_DATE_MODEL_PROVIDER,
model="gpt-4o",
custom_llm_provider="azure",
completion_tokens=400,
timed_completion_tokens=400,
total_response_time_ms=2000,
timed_requests=2,
),
_grouping_row(
_GROUP_DATE_MODEL_GROUP_PROVIDER,
model_group="public-gpt-4o",
custom_llm_provider="openai",
completion_tokens=900,
timed_completion_tokens=900,
total_response_time_ms=3000,
timed_requests=3,
),
]
day = _aggregate_grouping_sets_records_sync(records=records, api_key_metadata={})["results"][0]
assert day.breakdown.models["gpt-4o"].provider_breakdown["openai"].model_dump() == {
"timed_completion_tokens": 900,
"total_response_time_ms": 3000,
"timed_requests": 3,
"output_tokens_per_second": 300.0,
}
assert day.breakdown.models["gpt-4o"].provider_breakdown["azure"].output_tokens_per_second == 200.0
assert day.breakdown.model_groups["public-gpt-4o"].provider_breakdown["openai"].output_tokens_per_second == 300.0
def test_grouping_sets_dispatcher_returns_zero_for_timed_requests_without_completion_tokens():
from litellm.proxy.management_endpoints.common_daily_activity import (
_GROUP_DATE_MODEL_PROVIDER,
_aggregate_grouping_sets_records_sync,
)
records = [
_grouping_row(
_GROUP_DATE_MODEL_PROVIDER,
model="gpt-4o",
completion_tokens=900,
timed_completion_tokens=0,
total_response_time_ms=3000,
timed_requests=1,
)
]
day = _aggregate_grouping_sets_records_sync(records=records, api_key_metadata={})["results"][0]
assert day.breakdown.models["gpt-4o"].provider_breakdown["openai"].output_tokens_per_second == 0.0
def test_grouping_sets_dispatcher_returns_no_throughput_for_legacy_rows_without_timed_tokens():
from litellm.proxy.management_endpoints.common_daily_activity import (
_GROUP_DATE_MODEL_PROVIDER,
_aggregate_grouping_sets_records_sync,
)
records = [
_grouping_row(
_GROUP_DATE_MODEL_PROVIDER,
model="gpt-4o",
completion_tokens=900,
timed_completion_tokens=None,
total_response_time_ms=3000,
timed_requests=1,
)
]
day = _aggregate_grouping_sets_records_sync(records=records, api_key_metadata={})["results"][0]
assert day.breakdown.models["gpt-4o"].provider_breakdown["openai"].output_tokens_per_second is None
def test_combined_timed_tokens_propagates_unknown_measurements():
from litellm.proxy.management_endpoints.common_daily_activity import _combined_timed_completion_tokens
from litellm.types.proxy.management_endpoints.common_daily_activity import ProviderThroughputMetrics
existing = ProviderThroughputMetrics(timed_completion_tokens=10)
assert _combined_timed_completion_tokens(existing, None) is None
def test_grouping_sets_dispatcher_keeps_ptu_flat_cost_out_of_the_provider_breakdown():
"""Sentinel rows carry no provider, so their flat cost must not surface under the
"unknown" provider - the per-row path skips them for exactly the same reason."""
@ -1851,7 +1968,7 @@ def test_update_breakdown_metrics_covers_mcp_endpoint_and_entity(ptu_cost_attrib
endpoint="/v1/chat/completions",
spend=5.0,
prompt_tokens=0,
completion_tokens=0,
completion_tokens=600,
cache_read_input_tokens=0,
cache_creation_input_tokens=0,
compression_saved_tokens=0,
@ -1859,8 +1976,9 @@ def test_update_breakdown_metrics_covers_mcp_endpoint_and_entity(ptu_cost_attrib
prompt_caching_savings_spend=0,
gateway_injected_caching_savings_spend=0,
autorouter_savings_spend=0,
total_response_time_ms=0,
timed_requests=0,
total_response_time_ms=2000,
timed_requests=2,
timed_completion_tokens=600,
total_tokens=0,
api_requests=0,
successful_requests=0,
@ -1874,6 +1992,8 @@ def test_update_breakdown_metrics_covers_mcp_endpoint_and_entity(ptu_cost_attrib
assert "real-key" in breakdown.mcp_servers["srv/tool"].api_key_breakdown
assert "/v1/chat/completions" in breakdown.endpoints
assert "azure" in breakdown.providers
assert breakdown.models["gpt-4o-mini-ptu"].provider_breakdown["azure"].output_tokens_per_second == 300.0
assert breakdown.model_groups["grp"].provider_breakdown["azure"].output_tokens_per_second == 300.0
assert "team-1" in breakdown.entities
assert "real-key" in breakdown.entities["team-1"].api_key_breakdown
@ -2216,6 +2336,7 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown():
"autorouter_savings_spend": 0.0,
"total_response_time_ms": 0,
"timed_requests": 0,
"timed_completion_tokens": 0,
"failed_requests": 0,
"prompt_tokens": 0,
"completion_tokens": 0,

View file

@ -59,6 +59,7 @@ class _Activity:
failed_requests: int
total_response_time_ms: int
timed_requests: int
timed_completion_tokens: int
_ENTITY_CASES: Final[tuple[tuple[str, str, str], ...]] = (
@ -101,6 +102,7 @@ def _activity_for_entity(
failed_requests=0,
total_response_time_ms=100,
timed_requests=1,
timed_completion_tokens=5,
)
for api_key, date, model, spend, cache_read in key_rows
)
@ -157,6 +159,7 @@ def _seeded_activity() -> tuple[_Activity, ...]:
failed_requests=0,
total_response_time_ms=100,
timed_requests=1,
timed_completion_tokens=5,
)
for table in entity_ids
)
@ -180,6 +183,7 @@ def _metrics(rows: Sequence[_Activity]) -> Mapping[str, int | float]:
"failed_requests": sum(row.failed_requests for row in rows),
"total_response_time_ms": sum(row.total_response_time_ms for row in rows),
"timed_requests": sum(row.timed_requests for row in rows),
"timed_completion_tokens": sum(row.timed_completion_tokens for row in rows),
}

View file

@ -237,6 +237,7 @@ def test_aggregate_query_sums_all_savings_drivers_and_response_time() -> None:
fields: Final = tuple(field for field in SpendMetrics.model_fields if field.endswith("_savings_spend")) + (
"total_response_time_ms",
"timed_requests",
"timed_completion_tokens",
)
assert fields

View file

@ -76,6 +76,7 @@ const toMetric = (entry: SchemaMetricWithMetadata): MetricWithMetadata => ({
api_key_breakdown: Object.fromEntries(
Object.entries(entry.api_key_breakdown ?? {}).map(([key, value]) => [key, toKeyMetric(value)]),
),
provider_breakdown: entry.provider_breakdown ?? {},
});
const toMetricMap = (

View file

@ -56,6 +56,14 @@ const aggregatedResponse: DailyActivityAggregatedResponse = {
metrics: completeMetrics,
metadata: {},
api_key_breakdown: { "key-hash": apiKeyActivity },
provider_breakdown: {
openai: {
timed_completion_tokens: 900,
output_tokens_per_second: 300,
timed_requests: 3,
total_response_time_ms: 3000,
},
},
},
},
},
@ -105,6 +113,14 @@ describe("key activity data", () => {
},
},
]);
expect(toDailyData(aggregatedResponse)[0].breakdown.models["gpt-4o-mini"].provider_breakdown).toEqual({
openai: {
timed_completion_tokens: 900,
output_tokens_per_second: 300,
timed_requests: 3,
total_response_time_ms: 3000,
},
});
});
it("appends pages without duplicate keys and compares the server offset to the total", () => {

View file

@ -38,6 +38,14 @@ export interface MetricWithMetadata {
metrics: SpendMetrics;
metadata: object;
api_key_breakdown: { [key: string]: KeyMetricWithMetadata };
provider_breakdown?: { [key: string]: ProviderThroughputMetrics };
}
export interface ProviderThroughputMetrics {
timed_completion_tokens?: number | null;
total_response_time_ms: number;
timed_requests: number;
output_tokens_per_second?: number | null;
}
export interface KeyMetricWithMetadata {
@ -92,6 +100,7 @@ export interface ModelActivityData {
cache_creation_input_tokens: number;
avg_response_time_ms?: number | null;
};
provider_throughput?: Record<string, number | null>;
}[];
}

View file

@ -1,7 +1,13 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { ActivityMetrics, formatKeyLabel, processActivityData, ResponseTimeTooltip } from "./activity_metrics";
import {
ActivityMetrics,
formatKeyLabel,
processActivityData,
providerThroughputChartData,
ResponseTimeTooltip,
} from "./activity_metrics";
import type { ChartTooltipProps } from "@/components/shared/charts";
import { Team } from "./key_team_helpers/key_list";
import { DailyData, KeyMetricWithMetadata, ModelActivityData } from "./UsagePage/types";
@ -1402,6 +1408,38 @@ describe("processActivityData", () => {
expect(result["gpt-5.5"].total_timed_requests).toBe(0);
expect(result["gpt-5.5"].daily_data[0].metrics.avg_response_time_ms).toBeNull();
});
it("preserves daily provider throughput for model and model-group views", () => {
const metric = {
metrics: EMPTY_SPEND_METRICS,
metadata: {},
api_key_breakdown: {},
provider_breakdown: {
openai: {
timed_completion_tokens: 900,
total_response_time_ms: 3000,
timed_requests: 3,
output_tokens_per_second: 300,
},
},
};
const activity: { results: DailyData[] } = {
results: [
createMockDailyData("2025-01-01", EMPTY_SPEND_METRICS, {
...EMPTY_BREAKDOWN,
models: { "gpt-4o": metric },
model_groups: { "public-gpt-4o": metric },
}),
],
};
expect(processActivityData(activity, "models")["gpt-4o"].daily_data[0].provider_throughput).toEqual({
openai: 300,
});
expect(processActivityData(activity, "model_groups")["public-gpt-4o"].daily_data[0].provider_throughput).toEqual({
openai: 300,
});
});
});
describe("ActivityMetrics response time", () => {
@ -1482,6 +1520,58 @@ describe("ActivityMetrics response time", () => {
});
});
describe("ActivityMetrics provider throughput", () => {
const model = createMockModelActivityData("GPT-4o", {
daily_data: [
{
...createMockModelActivityData("GPT-4o").daily_data[0],
date: "2025-01-01",
provider_throughput: { openai: 300, azure: 200 },
},
{
...createMockModelActivityData("GPT-4o").daily_data[0],
date: "2025-01-02",
provider_throughput: { openai: 250 },
},
],
});
it("renders one daily line per provider", () => {
render(<ActivityMetrics modelMetrics={{ "gpt-4o": model }} />);
expect(screen.getByText("Output tokens per second of response time")).toBeInTheDocument();
expect(screen.getByText("Openai")).toBeInTheDocument();
expect(screen.getByText("Azure")).toBeInTheDocument();
expect(screen.getAllByText("2025-01-01").length).toBeGreaterThan(0);
expect(screen.getAllByText("2025-01-02").length).toBeGreaterThan(0);
});
it("keeps missing provider dates as gaps", () => {
expect(providerThroughputChartData(model.daily_data)).toEqual({
providers: ["azure", "openai"],
data: [
{ date: "2025-01-01", azure: 200, openai: 300 },
{ date: "2025-01-02", azure: null, openai: 250 },
],
});
});
it("hides the chart when every throughput value is unavailable", () => {
const unavailable = createMockModelActivityData("GPT-4o", {
daily_data: [
{
...createMockModelActivityData("GPT-4o").daily_data[0],
provider_throughput: { openai: null },
},
],
});
render(<ActivityMetrics modelMetrics={{ "gpt-4o": unavailable }} />);
expect(screen.queryByText("Output tokens per second of response time")).not.toBeInTheDocument();
});
});
describe("formatKeyLabel", () => {
it("should return key_alias when no team_id is present", () => {
const modelData = createMockKeyMetricWithMetadata({

View file

@ -4,6 +4,7 @@ import {
type ChartTooltipProps,
CustomLegend,
CustomTooltip,
DEFAULT_COLOR_CYCLE,
formatCategoryName,
LineChart,
ValueTooltip,
@ -18,7 +19,13 @@ import { Team } from "./key_team_helpers/key_list";
import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView";
import { keyActivityLabel } from "./UsagePage/keyActivityLabel";
import type { ModelTopKeysResponse } from "./UsagePage/dailyActivityApi";
import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopModelData } from "./UsagePage/types";
import {
DailyData,
KeyMetricWithMetadata,
MetricWithMetadata,
ModelActivityData,
TopModelData,
} from "./UsagePage/types";
import { averageResponseTimeMs, formatResponseTime, valueFormatter } from "./UsagePage/utils/value_formatters";
interface ActivityMetricsProps {
@ -41,6 +48,30 @@ export const ResponseTimeTooltip = ({ active, payload, label }: ChartTooltipProp
/>
);
const formatTokensPerSecond = (value: number): string =>
`${value.toLocaleString(undefined, { maximumFractionDigits: 2 })} tokens/s`;
export const providerThroughputChartData = (dailyData: ModelActivityData["daily_data"]) => {
const providers = Array.from(new Set(dailyData.flatMap((day) => Object.keys(day.provider_throughput ?? {}))))
.filter((provider) =>
dailyData.some((day) => {
const value = day.provider_throughput?.[provider];
return typeof value === "number" && Number.isFinite(value);
}),
)
.sort();
const data = dailyData.map((day) => ({
date: day.date,
...Object.fromEntries(
providers.map((provider) => {
const value = day.provider_throughput?.[provider];
return [provider, typeof value === "number" && Number.isFinite(value) ? value : null];
}),
),
}));
return { providers, data };
};
const ModelTopKeys = ({
modelName,
fetchTopApiKeys,
@ -178,6 +209,8 @@ export const ModelSection = ({
hidePromptCachingMetrics?: boolean;
fetchTopApiKeys?: (model: string) => Promise<ModelTopKeysResponse>;
}) => {
const throughputChart = providerThroughputChartData(metrics.daily_data);
return (
<div className="space-y-2">
{/* Summary Cards */}
@ -316,6 +349,27 @@ export const ModelSection = ({
</Card>
)}
{throughputChart.providers.length > 0 && (
<Card>
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">Output tokens per second of response time</h3>
<CustomLegend categories={throughputChart.providers} colors={DEFAULT_COLOR_CYCLE} />
</div>
<LineChart
className="mt-4"
data={throughputChart.data}
index="date"
categories={throughputChart.providers}
colors={DEFAULT_COLOR_CYCLE}
valueFormatter={formatTokensPerSecond}
connectNulls={false}
showLegend={false}
/>
</CardContent>
</Card>
)}
<Card>
<CardContent>
<div className="flex justify-between items-center">
@ -683,6 +737,13 @@ export const processActivityData = (
modelMetrics[model].total_response_time_ms =
(modelMetrics[model].total_response_time_ms ?? 0) + dayResponseTimeMs;
modelMetrics[model].total_timed_requests = (modelMetrics[model].total_timed_requests ?? 0) + dayTimedRequests;
const providerBreakdown = (modelData as MetricWithMetadata).provider_breakdown ?? {};
const providerThroughput = Object.fromEntries(
Object.entries(providerBreakdown).map(([provider, providerMetrics]) => [
provider,
providerMetrics.output_tokens_per_second ?? null,
]),
);
// Add daily data
modelMetrics[model].daily_data.push({
@ -699,6 +760,7 @@ export const processActivityData = (
cache_creation_input_tokens: modelData.metrics.cache_creation_input_tokens || 0,
avg_response_time_ms: averageResponseTimeMs(dayResponseTimeMs, dayTimedRequests),
},
...(Object.keys(providerThroughput).length > 0 ? { provider_throughput: providerThroughput } : {}),
});
});
});

View file

@ -38122,6 +38122,10 @@ export interface components {
[key: string]: unknown;
};
metrics: components["schemas"]["SpendMetrics"];
/** Provider Breakdown */
provider_breakdown?: {
[key: string]: components["schemas"]["ProviderThroughputMetrics"];
};
};
/** Mode */
Mode: {
@ -41755,6 +41759,23 @@ export interface components {
/** Tooltip */
tooltip?: string | null;
};
/** ProviderThroughputMetrics */
ProviderThroughputMetrics: {
/** Output Tokens Per Second */
output_tokens_per_second?: number | null;
/** Timed Completion Tokens */
timed_completion_tokens?: number | null;
/**
* Timed Requests
* @default 0
*/
timed_requests: number;
/**
* Total Response Time Ms
* @default 0
*/
total_response_time_ms: number;
};
/**
* ProxyChatCompletionRequest
* @description Pydantic model for chat completion requests that includes both OpenAI standard fields