mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
Merge 01fafb270c into 461a58c40a
This commit is contained in:
commit
cbc7273d5c
26 changed files with 758 additions and 127 deletions
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -348,6 +348,7 @@ _PRISMA_MODELS: Final[frozenset[str]] = frozenset(
|
|||
"LiteLLM_WorkflowRun",
|
||||
"LiteLLM_WorkflowEvent",
|
||||
"LiteLLM_WorkflowMessage",
|
||||
"LiteLLM_BackgroundInteractionSettlement",
|
||||
"LiteLLM_Lens",
|
||||
"LiteLLM_LensRun",
|
||||
"LiteLLM_LensWorker",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
21
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
21
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue