Merge pull request #41313 from BerriAI/litellm_model_activity_response_time

feat(ui): show average response time per model in usage model activity
This commit is contained in:
Yassin Kortam 2026-09-15 18:43:48 -07:00 committed by GitHub
commit 67cb0bc089
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 589 additions and 13 deletions

View file

@ -0,0 +1,23 @@
-- AlterTable
ALTER TABLE "LiteLLM_DailyUserSpend" ADD COLUMN IF NOT EXISTS "total_response_time_ms" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "LiteLLM_DailyUserSpend" ADD COLUMN IF NOT EXISTS "timed_requests" BIGINT NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyOrganizationSpend" ADD COLUMN IF NOT EXISTS "total_response_time_ms" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "LiteLLM_DailyOrganizationSpend" ADD COLUMN IF NOT EXISTS "timed_requests" BIGINT NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyEndUserSpend" ADD COLUMN IF NOT EXISTS "total_response_time_ms" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "LiteLLM_DailyEndUserSpend" ADD COLUMN IF NOT EXISTS "timed_requests" BIGINT NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyAgentSpend" ADD COLUMN IF NOT EXISTS "total_response_time_ms" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "LiteLLM_DailyAgentSpend" ADD COLUMN IF NOT EXISTS "timed_requests" BIGINT NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyTeamSpend" ADD COLUMN IF NOT EXISTS "total_response_time_ms" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "LiteLLM_DailyTeamSpend" ADD COLUMN IF NOT EXISTS "timed_requests" BIGINT NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyTagSpend" ADD COLUMN IF NOT EXISTS "total_response_time_ms" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE "LiteLLM_DailyTagSpend" ADD COLUMN IF NOT EXISTS "timed_requests" BIGINT NOT NULL DEFAULT 0;

View file

@ -801,6 +801,8 @@ model LiteLLM_DailyUserSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -837,6 +839,8 @@ model LiteLLM_DailyOrganizationSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -873,6 +877,8 @@ model LiteLLM_DailyEndUserSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
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])
@ -908,6 +914,8 @@ model LiteLLM_DailyAgentSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([agent_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint])
@ -943,6 +951,8 @@ model LiteLLM_DailyTeamSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
ptu_flat_cost Float @default(0.0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -981,6 +991,8 @@ model LiteLLM_DailyTagSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View file

@ -3125,6 +3125,11 @@
"title": "Total Prompt Tokens",
"type": "integer"
},
"total_response_time_ms": {
"default": 0,
"title": "Total Response Time Ms",
"type": "integer"
},
"total_spend": {
"default": 0.0,
"title": "Total Spend",
@ -3135,6 +3140,11 @@
"title": "Total Successful Requests",
"type": "integer"
},
"total_timed_requests": {
"default": 0,
"title": "Total Timed Requests",
"type": "integer"
},
"total_tokens": {
"default": 0,
"title": "Total Tokens",
@ -3643,6 +3653,16 @@
"title": "Successful Requests",
"type": "integer"
},
"timed_requests": {
"default": 0,
"title": "Timed Requests",
"type": "integer"
},
"total_response_time_ms": {
"default": 0,
"title": "Total Response Time Ms",
"type": "integer"
},
"total_tokens": {
"default": 0,
"title": "Total Tokens",

View file

@ -5220,6 +5220,8 @@ class BaseDailySpendTransaction(TypedDict):
api_requests: int
successful_requests: int
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
class DailyTeamSpendTransaction(BaseDailySpendTransaction):

View file

@ -57,6 +57,8 @@ _COUNTER_COLUMNS: Final = (
"cache_read_input_tokens",
"cache_creation_input_tokens",
"compression_saved_tokens",
"total_response_time_ms",
"timed_requests",
)
_SPEND_COLUMNS: Final = (
"spend",

View file

@ -137,6 +137,19 @@ class _SpendTransactionManager(Protocol):
async def __aexit__(self, exc_type: object, exc_value: object, traceback: object) -> bool | None: ...
def _timed_request_duration_ms(
payload: dict | SpendLogsPayload,
request_status: Literal["success", "failure"],
is_internal_call: bool,
) -> int | None:
if is_internal_call or request_status != "success":
return None
duration_ms: Final = payload.get("request_duration_ms")
if not isinstance(duration_ms, int) or duration_ms < 0:
return None
return duration_ms
def _spend_update_tx(prisma_client: PrismaClient) -> _SpendTransactionManager:
tx: Final[_SpendTransactionManager] = prisma_client.db.tx(timeout=timedelta(seconds=60))
return tx
@ -2232,6 +2245,7 @@ class DBSpendUpdateWriter:
recorded_autorouter_savings=_metadata.get("autorouter_savings"),
billed_at=payload.get("endTime"),
)
timed_duration_ms: Final = _timed_request_duration_ms(payload, request_status, is_internal_call)
daily_transaction: Final = BaseDailySpendTransaction(
date=date,
@ -2259,6 +2273,8 @@ class DBSpendUpdateWriter:
prompt_caching_savings_spend=savings_spend.prompt_caching,
gateway_injected_caching_savings_spend=savings_spend.gateway_injected_caching,
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,
)
return daily_transaction
except Exception as e:

View file

@ -142,6 +142,14 @@ class DailySpendUpdateQueue(BaseUpdateQueue):
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

View file

@ -114,6 +114,12 @@ class DailySpendRecord(Protocol):
@property
def failed_requests(self) -> int: ...
@property
def total_response_time_ms(self) -> int: ...
@property
def timed_requests(self) -> int: ...
class _KeyMetadataDict(TypedDict, total=False):
key_alias: ReadOnly[str | None]
@ -162,6 +168,8 @@ class _GroupingSetsRow(SimpleNamespace):
api_requests: int | None
successful_requests: int | None
failed_requests: int | None
total_response_time_ms: int | None
timed_requests: int | None
class _EntityRollupRow(_GroupingSetsRow):
@ -217,6 +225,8 @@ def update_metrics(existing_metrics: SpendMetrics, record: DailySpendRecord) ->
existing_metrics.api_requests += record.api_requests or 0
existing_metrics.successful_requests += record.successful_requests or 0
existing_metrics.failed_requests += record.failed_requests or 0
existing_metrics.total_response_time_ms += record.total_response_time_ms or 0
existing_metrics.timed_requests += record.timed_requests or 0
return existing_metrics
@ -767,7 +777,9 @@ def _build_aggregated_sql_query(
SUM(autorouter_savings_spend)::float AS autorouter_savings_spend,
SUM(api_requests)::bigint AS api_requests,
SUM(successful_requests)::bigint AS successful_requests,
SUM(failed_requests)::bigint AS failed_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
FROM "{pg_table}"
WHERE {where_clause}
GROUP BY GROUPING SETS (
@ -846,7 +858,9 @@ def _build_entity_rollup_sql_query(
SUM(autorouter_savings_spend)::float AS autorouter_savings_spend,
SUM(api_requests)::bigint AS api_requests,
SUM(successful_requests)::bigint AS successful_requests,
SUM(failed_requests)::bigint AS failed_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
FROM "{pg_table}"
WHERE {where_clause}
GROUP BY GROUPING SETS (
@ -985,6 +999,8 @@ def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics:
api_requests=record.api_requests or 0,
successful_requests=record.successful_requests or 0,
failed_requests=record.failed_requests or 0,
total_response_time_ms=record.total_response_time_ms or 0,
timed_requests=record.timed_requests or 0,
)
@ -1246,6 +1262,8 @@ async def get_daily_activity(
total_prompt_caching_savings_spend=metadata_metrics.prompt_caching_savings_spend,
total_gateway_injected_caching_savings_spend=metadata_metrics.gateway_injected_caching_savings_spend,
total_autorouter_savings_spend=metadata_metrics.autorouter_savings_spend,
total_response_time_ms=metadata_metrics.total_response_time_ms,
total_timed_requests=metadata_metrics.timed_requests,
page=page,
total_pages=-(-total_count // page_size), # Ceiling division
has_more=(page * page_size) < total_count,
@ -1423,6 +1441,8 @@ async def get_daily_activity_aggregated(
"totals"
].gateway_injected_caching_savings_spend,
total_autorouter_savings_spend=aggregated["totals"].autorouter_savings_spend,
total_response_time_ms=aggregated["totals"].total_response_time_ms,
total_timed_requests=aggregated["totals"].timed_requests,
page=1,
total_pages=1,
has_more=False,

View file

@ -801,6 +801,8 @@ model LiteLLM_DailyUserSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -837,6 +839,8 @@ model LiteLLM_DailyOrganizationSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -873,6 +877,8 @@ model LiteLLM_DailyEndUserSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
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])
@ -908,6 +914,8 @@ model LiteLLM_DailyAgentSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([agent_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint])
@ -943,6 +951,8 @@ model LiteLLM_DailyTeamSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
ptu_flat_cost Float @default(0.0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -981,6 +991,8 @@ model LiteLLM_DailyTagSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View file

@ -32,6 +32,8 @@ class SpendMetrics(BaseModel):
successful_requests: int = Field(default=0)
failed_requests: int = Field(default=0)
api_requests: int = Field(default=0)
total_response_time_ms: int = Field(default=0)
timed_requests: int = Field(default=0)
class MetricBase(BaseModel):
@ -93,6 +95,8 @@ class DailySpendMetadata(BaseModel):
total_prompt_caching_savings_spend: float = Field(default=0.0)
total_gateway_injected_caching_savings_spend: float = Field(default=0.0)
total_autorouter_savings_spend: float = Field(default=0.0)
total_response_time_ms: int = Field(default=0)
total_timed_requests: int = Field(default=0)
page: int = Field(default=1)
total_pages: int = Field(default=1)
has_more: bool = Field(default=False)
@ -125,6 +129,8 @@ class LiteLLM_DailyUserSpend(BaseModel):
api_requests: int = 0
successful_requests: int = 0
failed_requests: int = 0
total_response_time_ms: int = 0
timed_requests: int = 0
class GroupedData(TypedDict):

View file

@ -801,6 +801,8 @@ model LiteLLM_DailyUserSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -837,6 +839,8 @@ model LiteLLM_DailyOrganizationSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -873,6 +877,8 @@ model LiteLLM_DailyEndUserSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
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])
@ -908,6 +914,8 @@ model LiteLLM_DailyAgentSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([agent_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint])
@ -943,6 +951,8 @@ model LiteLLM_DailyTeamSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
ptu_flat_cost Float @default(0.0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@ -981,6 +991,8 @@ model LiteLLM_DailyTagSpend {
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
failed_requests BigInt @default(0)
total_response_time_ms BigInt @default(0)
timed_requests BigInt @default(0)
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View file

@ -209,6 +209,8 @@ async def test_get_aggregated_daily_spend_update_transactions_same_key():
"prompt_caching_savings_spend": 0,
"gateway_injected_caching_savings_spend": 0,
"autorouter_savings_spend": 0,
"total_response_time_ms": 0,
"timed_requests": 0,
}
updates = [{test_key: test_transaction1}, {test_key: test_transaction2}]
@ -261,6 +263,8 @@ async def test_flush_and_get_aggregated_daily_spend_update_transactions(
"prompt_caching_savings_spend": 0,
"gateway_injected_caching_savings_spend": 0,
"autorouter_savings_spend": 0,
"total_response_time_ms": 0,
"timed_requests": 0,
}
# Add updates to queue
@ -550,7 +554,7 @@ async def test_every_optional_daily_metric_aggregates(daily_spend_update_queue):
numeric_fields = [
name for name, annotation in BaseDailySpendTransaction.__annotations__.items() if _numeric(annotation)
]
assert "autorouter_savings_spend" in numeric_fields
assert {"autorouter_savings_spend", "total_response_time_ms", "timed_requests"} <= set(numeric_fields)
increments = {field: index + 1 for index, field in enumerate(numeric_fields)}
await daily_spend_update_queue.add_update({test_key: dict(increments)})
@ -579,8 +583,12 @@ 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}})
await daily_spend_update_queue.add_update(
{test_key: {**base, "autorouter_savings_spend": 0.25, "total_response_time_ms": 900, "timed_requests": 1}}
)
await daily_spend_update_queue.aggregate_queue_updates()
updates = await daily_spend_update_queue.flush_all_updates_from_in_memory_queue()
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

View file

@ -85,10 +85,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
# 23 bound columns per row plus the inlined updated_at, so the row count is what
# 25 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 * 23
assert "$2300::text" in sql
assert len(params) == 100 * 25
assert "$2500::text" in sql
assert sql.count("(NOW() AT TIME ZONE 'UTC')") == 100 + 1
@ -104,7 +104,16 @@ def test_conflict_target_is_the_full_unique_constraint():
@pytest.mark.parametrize(
"column",
["prompt_tokens", "completion_tokens", "spend", "api_requests", "successful_requests", "failed_requests"],
[
"prompt_tokens",
"completion_tokens",
"spend",
"api_requests",
"successful_requests",
"failed_requests",
"total_response_time_ms",
"timed_requests",
],
)
def test_counters_increment_rather_than_overwrite(column):
"""An overwrite would silently discard every earlier flush's spend for that row."""

View file

@ -2864,6 +2864,76 @@ async def test_daily_transaction_internal_call_keeps_spend_but_not_request_count
assert user_sent["successful_requests"] == 1
def _response_time_payload(request_duration_ms: object, metadata: dict | None = None) -> dict:
return {
"request_id": "req-timed-1",
"user": "test-user",
"startTime": "2026-09-15T00:00:00",
"api_key": "test-key",
"model": "gpt-5.5",
"custom_llm_provider": "openai",
"model_group": "gpt-5.5",
"call_type": "acompletion",
"prompt_tokens": 10,
"completion_tokens": 5,
"spend": 0.01,
"request_duration_ms": request_duration_ms,
"metadata": json.dumps(metadata or {}),
}
@pytest.mark.asyncio
@pytest.mark.parametrize("request_duration_ms", [1234, 0])
async def test_daily_transaction_rolls_up_response_time_for_successful_requests(request_duration_ms: int):
"""A successful user-sent request contributes its request_duration_ms to the daily
response-time sum and counts as one timed request, including a 0 ms duration."""
writer = DBSpendUpdateWriter()
mock_prisma = MagicMock()
mock_prisma.get_request_status = MagicMock(return_value="success")
transaction = await writer._common_add_spend_log_transaction_to_daily_transaction(
payload=_response_time_payload(request_duration_ms),
prisma_client=mock_prisma,
type="user",
)
assert transaction is not None
assert transaction["total_response_time_ms"] == request_duration_ms
assert transaction["timed_requests"] == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
("request_status", "request_duration_ms", "metadata"),
[
("failure", 1234, {}),
("success", None, {}),
("success", -5, {}),
("success", "1234", {}),
("success", 1234, {"internal_call_origin": "shadow_eval_judge"}),
],
ids=["failed", "missing", "negative", "non_int", "internal_call"],
)
async def test_daily_transaction_excludes_untimed_requests_from_response_time(
request_status: str, request_duration_ms: object, metadata: dict
):
"""Failed, internal, and missing/invalid-duration requests never enter the response-time
average: both the duration sum and the timed_requests denominator stay at zero."""
writer = DBSpendUpdateWriter()
mock_prisma = MagicMock()
mock_prisma.get_request_status = MagicMock(return_value=request_status)
transaction = await writer._common_add_spend_log_transaction_to_daily_transaction(
payload=_response_time_payload(request_duration_ms, metadata),
prisma_client=mock_prisma,
type="user",
)
assert transaction is not None
assert transaction["total_response_time_ms"] == 0
assert transaction["timed_requests"] == 0
def _deadlock_error():
from prisma.errors import RawQueryError

View file

@ -157,6 +157,8 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
"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,
"failed_requests": 0,
}
mock_rows = [
@ -647,6 +649,8 @@ def test_update_breakdown_metrics_includes_user_email():
prompt_caching_savings_spend=0,
gateway_injected_caching_savings_spend=0,
autorouter_savings_spend=0,
total_response_time_ms=0,
timed_requests=0,
total_tokens=2,
api_requests=1,
successful_requests=1,
@ -722,6 +726,8 @@ async def test_tag_daily_activity_metadata_totals_not_zero():
mock_record_1.prompt_caching_savings_spend = 0.0
mock_record_1.gateway_injected_caching_savings_spend = 0.0
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.api_requests = 10
mock_record_1.successful_requests = 9
mock_record_1.failed_requests = 1
@ -746,6 +752,8 @@ async def test_tag_daily_activity_metadata_totals_not_zero():
mock_record_2.prompt_caching_savings_spend = 0.0
mock_record_2.gateway_injected_caching_savings_spend = 0.0
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.api_requests = 5
mock_record_2.successful_requests = 5
mock_record_2.failed_requests = 0
@ -778,6 +786,8 @@ async def test_tag_daily_activity_metadata_totals_not_zero():
assert result.metadata.total_successful_requests == 14 # 9 + 5
assert result.metadata.total_failed_requests == 1
assert result.metadata.total_tokens == 1100 # (500+200) + (300+100)
assert result.metadata.total_response_time_ms == 20_500
assert result.metadata.total_timed_requests == 14
# Verify breakdown still works
assert len(result.results) == 1
@ -786,6 +796,10 @@ async def test_tag_daily_activity_metadata_totals_not_zero():
assert "staging" in daily.breakdown.entities
assert daily.breakdown.entities["production"].metrics.spend == 25.0
assert daily.breakdown.entities["staging"].metrics.spend == 5.0
assert daily.breakdown.models["gpt-4"].metrics.total_response_time_ms == 18_000
assert daily.breakdown.models["gpt-4"].metrics.timed_requests == 9
assert daily.breakdown.models["gpt-3.5-turbo"].metrics.total_response_time_ms == 2_500
assert daily.breakdown.models["gpt-3.5-turbo"].metrics.timed_requests == 5
@pytest.mark.asyncio
@ -810,6 +824,8 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
"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,
"failed_requests": 0,
}
mock_rows = [
@ -900,6 +916,8 @@ def _daily_user_spend_record(*, user_id, api_key, spend, model="gpt-4", model_gr
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,
api_requests=1,
successful_requests=1,
failed_requests=0,
@ -1333,6 +1351,8 @@ async def test_get_daily_activity_aggregated_empty_result_set():
"prompt_caching_savings_spend": None,
"gateway_injected_caching_savings_spend": None,
"autorouter_savings_spend": None,
"total_response_time_ms": None,
"timed_requests": None,
"api_requests": None,
"successful_requests": None,
"failed_requests": None,
@ -1378,6 +1398,8 @@ def _no_spend_record():
prompt_caching_savings_spend=None,
gateway_injected_caching_savings_spend=None,
autorouter_savings_spend=None,
total_response_time_ms=None,
timed_requests=None,
api_requests=None,
successful_requests=None,
failed_requests=None,
@ -1465,6 +1487,55 @@ class TestEverySavingsDriverSurvivesTheReadPath:
)
class TestResponseTimeSurvivesTheReadPath:
"""The dashboard averages total_response_time_ms over timed_requests, so both halves
of the pair must be summed by the rollup query, accumulated across rows, carried by
a single-row conversion, and coalesced when a NULL aggregate comes back."""
_FIELDS = ("total_response_time_ms", "timed_requests")
def test_both_halves_are_summed_by_the_rollup_query(self):
sql, _ = _build_aggregated_sql_query(
table_name="litellm_dailyuserspend",
entity_id_field="user_id",
entity_id="user-1",
start_date="2026-09-01",
end_date="2026-09-30",
model=None,
api_key=None,
timezone_offset_minutes=None,
)
for field in self._FIELDS:
assert f"SUM({field})" in sql, f"{field} is never summed, so the average reads as zero"
def test_accumulating_rows_keeps_sum_and_count_paired(self):
first = _no_spend_record()
first.total_response_time_ms = 1500
first.timed_requests = 2
second = _no_spend_record()
second.total_response_time_ms = 500
second.timed_requests = 1
metrics = update_metrics(update_metrics(SpendMetrics(), first), second)
assert metrics.total_response_time_ms == 2000
assert metrics.timed_requests == 3
def test_single_row_conversion_carries_both_halves(self):
record = _no_spend_record()
record.total_response_time_ms = 1234
record.timed_requests = 4
metrics = _record_to_spend_metrics(record)
assert metrics.total_response_time_ms == 1234
assert metrics.timed_requests == 4
def test_null_aggregates_read_as_zero(self):
metrics = _record_to_spend_metrics(_no_spend_record())
assert metrics.total_response_time_ms == 0
assert metrics.timed_requests == 0
accumulated = update_metrics(SpendMetrics(), _no_spend_record())
assert accumulated.total_response_time_ms == 0
assert accumulated.timed_requests == 0
@pytest.fixture
def ptu_cost_attribution_enabled(monkeypatch):
monkeypatch.setenv(PTU_COST_ATTRIBUTION_ENV_VAR, "true")
@ -1488,6 +1559,8 @@ def _spend_record(api_key, *, model="gpt-4o-mini-ptu", spend=0.0, ptu_flat_cost=
prompt_caching_savings_spend=0,
gateway_injected_caching_savings_spend=0,
autorouter_savings_spend=0,
total_response_time_ms=0,
timed_requests=0,
total_tokens=0,
api_requests=0,
successful_requests=0,
@ -1554,6 +1627,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,
api_requests=0,
successful_requests=0,
failed_requests=0,
@ -1714,6 +1789,8 @@ 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_tokens=0,
api_requests=0,
successful_requests=0,
@ -2118,6 +2195,8 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown():
"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,
"failed_requests": 0,
"prompt_tokens": 0,
"completion_tokens": 0,

View file

@ -43,6 +43,8 @@ describe("sumMetadata", () => {
total_cache_read_input_tokens: 1,
total_cache_creation_input_tokens: 1,
total_flat_cost: 1,
total_response_time_ms: 1,
total_timed_requests: 1,
};
const merged = sumMetadata(page, page);

View file

@ -30,6 +30,8 @@ const SUMMABLE_METADATA_KEYS = [
"total_cache_read_input_tokens",
"total_cache_creation_input_tokens",
"total_flat_cost",
"total_response_time_ms",
"total_timed_requests",
] as const;
interface DailyActivityResponse {
@ -78,6 +80,8 @@ const EMPTY_DATA: DailyActivityResponse = {
total_failed_requests: 0,
total_cache_read_input_tokens: 0,
total_cache_creation_input_tokens: 0,
total_response_time_ms: 0,
total_timed_requests: 0,
total_pages: 1,
has_more: false,
page: 1,

View file

@ -14,6 +14,8 @@ export interface SpendMetrics {
prompt_caching_savings_spend?: number;
gateway_injected_caching_savings_spend?: number;
autorouter_savings_spend?: number;
total_response_time_ms?: number;
timed_requests?: number;
}
export type DailyData = {
@ -81,6 +83,8 @@ export interface ModelActivityData {
prompt_tokens: number;
completion_tokens: number;
total_spend: number;
total_response_time_ms?: number;
total_timed_requests?: number;
top_api_keys: TopApiKeyData[];
top_models: TopModelData[];
daily_data: {
@ -95,6 +99,7 @@ export interface ModelActivityData {
failed_requests: number;
cache_read_input_tokens: number;
cache_creation_input_tokens: number;
avg_response_time_ms?: number | null;
};
}[];
}

View file

@ -1,5 +1,36 @@
import { describe, expect, it } from "vitest";
import { valueFormatter, valueFormatterSpend } from "./value_formatters";
import { averageResponseTimeMs, formatResponseTime, valueFormatter, valueFormatterSpend } from "./value_formatters";
describe("averageResponseTimeMs", () => {
it("divides the summed duration by the number of timed requests", () => {
expect(averageResponseTimeMs(6000, 4)).toBe(1500);
expect(averageResponseTimeMs(0, 3)).toBe(0);
});
it("returns null instead of dividing by zero when nothing was timed", () => {
expect(averageResponseTimeMs(0, 0)).toBeNull();
expect(averageResponseTimeMs(1200, 0)).toBeNull();
});
});
describe("formatResponseTime", () => {
it("shows sub-second durations in whole milliseconds", () => {
expect(formatResponseTime(0)).toBe("0ms");
expect(formatResponseTime(412.6)).toBe("413ms");
expect(formatResponseTime(999)).toBe("999ms");
});
it("shows durations of a second or more in seconds with two decimals", () => {
expect(formatResponseTime(1000)).toBe("1.00s");
expect(formatResponseTime(1500)).toBe("1.50s");
expect(formatResponseTime(12345)).toBe("12.35s");
});
it("shows a dash when there is no average to display", () => {
expect(formatResponseTime(null)).toBe("-");
expect(formatResponseTime(undefined)).toBe("-");
});
});
describe("valueFormatter", () => {
it("should format numbers >= 1,000,000 as millions with 2 decimal places", () => {

View file

@ -11,6 +11,17 @@ export function valueFormatter(number: number) {
return number.toString();
}
export function averageResponseTimeMs(totalResponseTimeMs: number, timedRequests: number): number | null {
if (timedRequests <= 0) return null;
return totalResponseTimeMs / timedRequests;
}
export function formatResponseTime(ms: number | null | undefined) {
if (ms == null) return "-";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
export function valueFormatterSpend(number: number) {
if (number === 0) return "$0";
if (number >= 1_000_000_000) {

View file

@ -1,7 +1,8 @@
import { fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { ActivityMetrics, formatKeyLabel, processActivityData } from "./activity_metrics";
import { ActivityMetrics, formatKeyLabel, processActivityData, 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";
@ -1424,6 +1425,144 @@ describe("processActivityData", () => {
expect(result).toEqual({});
});
it("sums response time per model and derives a per-day average over timed requests", () => {
const dayWithModel = (date: string, metrics: Partial<typeof EMPTY_SPEND_METRICS> & Record<string, number>) =>
createMockDailyData(date, EMPTY_SPEND_METRICS, {
...EMPTY_BREAKDOWN,
models: {
"gpt-5.5": { metrics: { ...EMPTY_SPEND_METRICS, ...metrics }, metadata: {}, api_key_breakdown: {} },
},
});
const fourTimedRequests = {
api_requests: 4,
successful_requests: 4,
total_response_time_ms: 6000,
timed_requests: 4,
};
const oneTimedOneFailed = {
api_requests: 2,
successful_requests: 1,
failed_requests: 1,
total_response_time_ms: 500,
timed_requests: 1,
};
const onlyFailures = { api_requests: 1, successful_requests: 0, failed_requests: 1 };
const activity: { results: DailyData[] } = {
results: [
dayWithModel("2025-01-02", fourTimedRequests),
dayWithModel("2025-01-01", oneTimedOneFailed),
dayWithModel("2025-01-03", onlyFailures),
],
};
const result = processActivityData(activity, "models");
expect(result["gpt-5.5"].total_response_time_ms).toBe(6500);
expect(result["gpt-5.5"].total_timed_requests).toBe(5);
expect(result["gpt-5.5"].daily_data.map((day) => day.metrics.avg_response_time_ms)).toEqual([500, 1500, null]);
});
it("treats rollups written before response time existed as zero timed requests", () => {
const activity: { results: DailyData[] } = {
results: [
createMockDailyData("2025-01-01", EMPTY_SPEND_METRICS, {
...EMPTY_BREAKDOWN,
models: {
"gpt-5.5": {
metrics: { ...EMPTY_SPEND_METRICS, api_requests: 3, successful_requests: 3 },
metadata: {},
api_key_breakdown: {},
},
},
}),
],
};
const result = processActivityData(activity, "models");
expect(result["gpt-5.5"].total_response_time_ms).toBe(0);
expect(result["gpt-5.5"].total_timed_requests).toBe(0);
expect(result["gpt-5.5"].daily_data[0].metrics.avg_response_time_ms).toBeNull();
});
});
describe("ActivityMetrics response time", () => {
const timedModel = createMockModelActivityData("GPT-5.5", {
total_response_time_ms: 6000,
total_timed_requests: 4,
daily_data: [
{
date: "2025-01-01",
metrics: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
api_requests: 3,
spend: 1,
successful_requests: 3,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
avg_response_time_ms: 2000,
},
},
{
date: "2025-01-02",
metrics: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
api_requests: 1,
spend: 1,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
avg_response_time_ms: 1000,
},
},
],
});
it("shows the model's average response time in the summary card and the collapsed header", () => {
render(<ActivityMetrics modelMetrics={{ "gpt-5.5": timedModel }} />);
expect(screen.getByText("Avg Response Time")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "1.50s" })).toBeInTheDocument();
expect(screen.getByText("over 4 timed successful requests")).toBeInTheDocument();
expect(screen.getByText("1.50s avg response")).toBeInTheDocument();
});
it("renders the per-day response time chart with duration-formatted axis ticks", () => {
render(<ActivityMetrics modelMetrics={{ "gpt-5.5": timedModel }} />);
expect(screen.getByText("Avg Response Time per day")).toBeInTheDocument();
expect(screen.getByText("Avg Response Time Ms")).toBeInTheDocument();
expect(screen.getAllByText(/^\d+(\.\d+)?(ms|s)$/).length).toBeGreaterThan(1);
});
it("labels the chart tooltip with the readable series name and a formatted duration", () => {
const payload = [
{ dataKey: "metrics.avg_response_time_ms", value: 1500, color: "#f59e0b", payload: timedModel.daily_data[0] },
] as NonNullable<ChartTooltipProps["payload"]>;
render(<ResponseTimeTooltip active={true} payload={payload} label="2025-01-01" />);
expect(screen.getByText("Avg Response Time Ms")).toBeInTheDocument();
expect(screen.getByText("1.50s")).toBeInTheDocument();
expect(screen.queryByText("metrics.avg_response_time_ms")).not.toBeInTheDocument();
});
it("shows a dash and no response time chart when the model has no timed requests", () => {
render(<ActivityMetrics modelMetrics={{ "gpt-5.5": createMockModelActivityData("GPT-5.5") }} />);
expect(screen.getByText("Avg Response Time")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "-" })).toBeInTheDocument();
expect(screen.getByText("over 0 timed successful requests")).toBeInTheDocument();
expect(screen.queryByText(/avg response$/)).not.toBeInTheDocument();
expect(screen.queryByText("Avg Response Time per day")).not.toBeInTheDocument();
expect(screen.queryByText("Avg Response Time Ms")).not.toBeInTheDocument();
});
});
describe("formatKeyLabel", () => {

View file

@ -1,4 +1,13 @@
import { AreaChart, BarChart, CustomLegend, CustomTooltip } from "@/components/shared/charts";
import {
AreaChart,
BarChart,
type ChartTooltipProps,
CustomLegend,
CustomTooltip,
formatCategoryName,
LineChart,
ValueTooltip,
} from "@/components/shared/charts";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { resolveTeamAliasFromTeamID } from "@/utils/teamUtils";
import { Card, CardContent } from "@/components/ui/card";
@ -9,13 +18,25 @@ import { Team } from "./key_team_helpers/key_list";
import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView";
import { keyActivityLabel } from "./UsagePage/keyActivityLabel";
import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopApiKeyData, TopModelData } from "./UsagePage/types";
import { valueFormatter } from "./UsagePage/utils/value_formatters";
import { averageResponseTimeMs, formatResponseTime, valueFormatter } from "./UsagePage/utils/value_formatters";
interface ActivityMetricsProps {
modelMetrics: Record<string, ModelActivityData>;
hidePromptCachingMetrics?: boolean;
}
const modelAverageResponseTimeMs = (metrics: ModelActivityData): number | null =>
averageResponseTimeMs(metrics.total_response_time_ms ?? 0, metrics.total_timed_requests ?? 0);
export const ResponseTimeTooltip = ({ active, payload, label }: ChartTooltipProps) => (
<ValueTooltip
active={active}
payload={payload?.map((item) => ({ ...item, name: formatCategoryName(String(item.dataKey ?? "")) }))}
label={label}
valueFormatter={formatResponseTime}
/>
);
const ModelSection = ({
modelName,
metrics,
@ -28,7 +49,7 @@ const ModelSection = ({
return (
<div className="space-y-2">
{/* Summary Cards */}
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-5 gap-4">
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Requests</p>
@ -62,6 +83,17 @@ const ModelSection = ({
</p>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Avg Response Time</p>
<h3 className="text-lg font-medium text-foreground">
{formatResponseTime(modelAverageResponseTimeMs(metrics))}
</h3>
<p className="text-sm text-muted-foreground">
over {(metrics.total_timed_requests ?? 0).toLocaleString()} timed successful requests
</p>
</CardContent>
</Card>
</div>
{metrics.top_api_keys && metrics.top_api_keys.length > 0 && (
@ -154,6 +186,28 @@ const ModelSection = ({
</CardContent>
</Card>
{(metrics.total_timed_requests ?? 0) > 0 && (
<Card>
<CardContent>
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-foreground">Avg Response Time per day</h3>
<CustomLegend categories={["metrics.avg_response_time_ms"]} colors={["amber"]} />
</div>
<LineChart
className="mt-4"
data={metrics.daily_data}
index="date"
categories={["metrics.avg_response_time_ms"]}
colors={["amber"]}
valueFormatter={formatResponseTime}
customTooltip={ResponseTimeTooltip}
connectNulls={true}
showLegend={false}
/>
</CardContent>
</Card>
)}
<Card>
<CardContent>
<div className="flex justify-between items-center">
@ -416,6 +470,9 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
<div className="flex space-x-4 text-sm text-muted-foreground">
<span>${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)}</span>
<span>{modelMetrics[modelName].total_requests.toLocaleString()} requests</span>
{modelAverageResponseTimeMs(modelMetrics[modelName]) != null && (
<span>{formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response</span>
)}
</div>
</div>
}
@ -471,11 +528,15 @@ export const processActivityData = (
total_spend: 0,
total_cache_read_input_tokens: 0,
total_cache_creation_input_tokens: 0,
total_response_time_ms: 0,
total_timed_requests: 0,
top_api_keys: [],
top_models: [],
daily_data: [],
};
}
const dayResponseTimeMs = modelData.metrics.total_response_time_ms || 0;
const dayTimedRequests = modelData.metrics.timed_requests || 0;
// Update totals
modelMetrics[model].total_requests += modelData.metrics.api_requests;
modelMetrics[model].prompt_tokens += modelData.metrics.prompt_tokens;
@ -486,6 +547,9 @@ export const processActivityData = (
modelMetrics[model].total_failed_requests += modelData.metrics.failed_requests;
modelMetrics[model].total_cache_read_input_tokens += modelData.metrics.cache_read_input_tokens || 0;
modelMetrics[model].total_cache_creation_input_tokens += modelData.metrics.cache_creation_input_tokens || 0;
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;
// Add daily data
modelMetrics[model].daily_data.push({
@ -500,6 +564,7 @@ export const processActivityData = (
failed_requests: modelData.metrics.failed_requests,
cache_read_input_tokens: modelData.metrics.cache_read_input_tokens || 0,
cache_creation_input_tokens: modelData.metrics.cache_creation_input_tokens || 0,
avg_response_time_ms: averageResponseTimeMs(dayResponseTimeMs, dayTimedRequests),
},
});
});

View file

@ -27563,6 +27563,11 @@ export interface components {
* @default 0
*/
total_prompt_tokens: number;
/**
* Total Response Time Ms
* @default 0
*/
total_response_time_ms: number;
/**
* Total Spend
* @default 0
@ -27573,6 +27578,11 @@ export interface components {
* @default 0
*/
total_successful_requests: number;
/**
* Total Timed Requests
* @default 0
*/
total_timed_requests: number;
/**
* Total Tokens
* @default 0
@ -37192,6 +37202,16 @@ export interface components {
* @default 0
*/
successful_requests: number;
/**
* Timed Requests
* @default 0
*/
timed_requests: number;
/**
* Total Response Time Ms
* @default 0
*/
total_response_time_ms: number;
/**
* Total Tokens
* @default 0