diff --git a/litellm/constants.py b/litellm/constants.py index 715d57e594d..50184871355 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1628,3 +1628,9 @@ ADVISOR_TOOL_DESCRIPTION: str = ( "want to verify your reasoning, or face a complex decision. " "Describe your question or challenge clearly in the 'question' field." ) + +# PTU reservation rollup writes rows to LiteLLM_DailyTeamSpend with this +# sentinel api_key so PTU flat cost stays distinguishable from real per-request +# spend under the table's composite unique constraint. +PTU_SENTINEL_API_KEY: str = "__ptu_reservation__" +PTU_ROLLUP_JOB_ID: str = "ptu_reservation_rollup_job" diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 60cb3ccd30d..54f7b9acefd 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -6,6 +6,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, U from fastapi import HTTPException, status from litellm._logging import verbose_proxy_logger +from litellm.constants import PTU_SENTINEL_API_KEY from litellm.proxy._types import CommonProxyErrors from litellm.proxy.utils import PrismaClient from litellm.repositories.table_repositories import DeletedVerificationTokenRepository @@ -44,6 +45,7 @@ def update_metrics(existing_metrics: SpendMetrics, record: Any) -> SpendMetrics: prompt_tokens = record.prompt_tokens or 0 completion_tokens = record.completion_tokens or 0 existing_metrics.spend += record.spend or 0.0 + existing_metrics.flat_cost += getattr(record, "ptu_flat_cost", None) or 0.0 existing_metrics.prompt_tokens += prompt_tokens existing_metrics.completion_tokens += completion_tokens existing_metrics.total_tokens += prompt_tokens + completion_tokens @@ -98,7 +100,15 @@ def update_breakdown_metrics( entity_id_field: Optional[str] = None, entity_metadata_field: Optional[Dict[str, dict]] = None, ) -> BreakdownMetrics: - """Updates breakdown metrics for a single record using the existing update_metrics function""" + """Updates breakdown metrics for a single record using the existing update_metrics function. + + PTU sentinel rows (``api_key == PTU_SENTINEL_API_KEY``) contribute their + ``ptu_flat_cost`` to every parent breakdown (per-model, per-provider, + per-endpoint, per-entity, per-day totals) but never appear as a row in + any ``api_keys`` / ``api_key_breakdown`` map — the sentinel string is not + a real key alias. + """ + is_ptu_sentinel = record.api_key == PTU_SENTINEL_API_KEY # Update model breakdown if record.model and record.model not in breakdown.models: @@ -110,18 +120,19 @@ def update_breakdown_metrics( breakdown.models[record.model].metrics = update_metrics(breakdown.models[record.model].metrics, record) # Update API key breakdown for this model - if record.api_key not in breakdown.models[record.model].api_key_breakdown: - breakdown.models[record.model].api_key_breakdown[record.api_key] = KeyMetricWithMetadata( - metrics=SpendMetrics(), - metadata=KeyMetadata( - key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), - team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), - ), + if not is_ptu_sentinel: + if record.api_key not in breakdown.models[record.model].api_key_breakdown: + breakdown.models[record.model].api_key_breakdown[record.api_key] = KeyMetricWithMetadata( + metrics=SpendMetrics(), + metadata=KeyMetadata( + key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), + team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), + ), + ) + breakdown.models[record.model].api_key_breakdown[record.api_key].metrics = update_metrics( + breakdown.models[record.model].api_key_breakdown[record.api_key].metrics, + record, ) - breakdown.models[record.model].api_key_breakdown[record.api_key].metrics = update_metrics( - breakdown.models[record.model].api_key_breakdown[record.api_key].metrics, - record, - ) # Update model group breakdown if record.model_group and record.model_group not in breakdown.model_groups: @@ -135,18 +146,19 @@ def update_breakdown_metrics( ) # Update API key breakdown for this model - if record.api_key not in breakdown.model_groups[record.model_group].api_key_breakdown: - breakdown.model_groups[record.model_group].api_key_breakdown[record.api_key] = KeyMetricWithMetadata( - metrics=SpendMetrics(), - metadata=KeyMetadata( - key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), - team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), - ), + if not is_ptu_sentinel: + if record.api_key not in breakdown.model_groups[record.model_group].api_key_breakdown: + breakdown.model_groups[record.model_group].api_key_breakdown[record.api_key] = KeyMetricWithMetadata( + metrics=SpendMetrics(), + metadata=KeyMetadata( + key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), + team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), + ), + ) + breakdown.model_groups[record.model_group].api_key_breakdown[record.api_key].metrics = update_metrics( + breakdown.model_groups[record.model_group].api_key_breakdown[record.api_key].metrics, + record, ) - breakdown.model_groups[record.model_group].api_key_breakdown[record.api_key].metrics = update_metrics( - breakdown.model_groups[record.model_group].api_key_breakdown[record.api_key].metrics, - record, - ) if record.mcp_namespaced_tool_name: if record.mcp_namespaced_tool_name not in breakdown.mcp_servers: @@ -159,23 +171,24 @@ def update_breakdown_metrics( ) # Update API key breakdown for this MCP server - if record.api_key not in breakdown.mcp_servers[record.mcp_namespaced_tool_name].api_key_breakdown: - breakdown.mcp_servers[record.mcp_namespaced_tool_name].api_key_breakdown[record.api_key] = ( - KeyMetricWithMetadata( - metrics=SpendMetrics(), - metadata=KeyMetadata( - key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), - team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), - ), + if not is_ptu_sentinel: + if record.api_key not in breakdown.mcp_servers[record.mcp_namespaced_tool_name].api_key_breakdown: + breakdown.mcp_servers[record.mcp_namespaced_tool_name].api_key_breakdown[record.api_key] = ( + KeyMetricWithMetadata( + metrics=SpendMetrics(), + metadata=KeyMetadata( + key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), + team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), + ), + ) ) - ) - breakdown.mcp_servers[record.mcp_namespaced_tool_name].api_key_breakdown[ - record.api_key - ].metrics = update_metrics( - breakdown.mcp_servers[record.mcp_namespaced_tool_name].api_key_breakdown[record.api_key].metrics, - record, - ) + breakdown.mcp_servers[record.mcp_namespaced_tool_name].api_key_breakdown[ + record.api_key + ].metrics = update_metrics( + breakdown.mcp_servers[record.mcp_namespaced_tool_name].api_key_breakdown[record.api_key].metrics, + record, + ) # Update provider breakdown provider = record.custom_llm_provider or "unknown" @@ -187,18 +200,19 @@ def update_breakdown_metrics( breakdown.providers[provider].metrics = update_metrics(breakdown.providers[provider].metrics, record) # Update API key breakdown for this provider - if record.api_key not in breakdown.providers[provider].api_key_breakdown: - breakdown.providers[provider].api_key_breakdown[record.api_key] = KeyMetricWithMetadata( - metrics=SpendMetrics(), - metadata=KeyMetadata( - key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), - team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), - ), + if not is_ptu_sentinel: + if record.api_key not in breakdown.providers[provider].api_key_breakdown: + breakdown.providers[provider].api_key_breakdown[record.api_key] = KeyMetricWithMetadata( + metrics=SpendMetrics(), + metadata=KeyMetadata( + key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), + team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), + ), + ) + breakdown.providers[provider].api_key_breakdown[record.api_key].metrics = update_metrics( + breakdown.providers[provider].api_key_breakdown[record.api_key].metrics, + record, ) - breakdown.providers[provider].api_key_breakdown[record.api_key].metrics = update_metrics( - breakdown.providers[provider].api_key_breakdown[record.api_key].metrics, - record, - ) # Update endpoint breakdown if record.endpoint: @@ -212,29 +226,31 @@ def update_breakdown_metrics( ) # Update API key breakdown for this endpoint - if record.api_key not in breakdown.endpoints[record.endpoint].api_key_breakdown: - breakdown.endpoints[record.endpoint].api_key_breakdown[record.api_key] = KeyMetricWithMetadata( + if not is_ptu_sentinel: + if record.api_key not in breakdown.endpoints[record.endpoint].api_key_breakdown: + breakdown.endpoints[record.endpoint].api_key_breakdown[record.api_key] = KeyMetricWithMetadata( + metrics=SpendMetrics(), + metadata=KeyMetadata( + key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), + team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), + ), + ) + breakdown.endpoints[record.endpoint].api_key_breakdown[record.api_key].metrics = update_metrics( + breakdown.endpoints[record.endpoint].api_key_breakdown[record.api_key].metrics, + record, + ) + + # Update api key breakdown + if not is_ptu_sentinel: + if record.api_key not in breakdown.api_keys: + breakdown.api_keys[record.api_key] = KeyMetricWithMetadata( metrics=SpendMetrics(), metadata=KeyMetadata( key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), - ), + ), # Add any api_key-specific metadata here ) - breakdown.endpoints[record.endpoint].api_key_breakdown[record.api_key].metrics = update_metrics( - breakdown.endpoints[record.endpoint].api_key_breakdown[record.api_key].metrics, - record, - ) - - # Update api key breakdown - if record.api_key not in breakdown.api_keys: - breakdown.api_keys[record.api_key] = KeyMetricWithMetadata( - metrics=SpendMetrics(), - metadata=KeyMetadata( - key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), - team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), - ), # Add any api_key-specific metadata here - ) - breakdown.api_keys[record.api_key].metrics = update_metrics(breakdown.api_keys[record.api_key].metrics, record) + breakdown.api_keys[record.api_key].metrics = update_metrics(breakdown.api_keys[record.api_key].metrics, record) # Update entity-specific metrics if entity_id_field is provided if entity_id_field: @@ -248,18 +264,19 @@ def update_breakdown_metrics( breakdown.entities[entity_value].metrics = update_metrics(breakdown.entities[entity_value].metrics, record) # Update API key breakdown for this entity - if record.api_key not in breakdown.entities[entity_value].api_key_breakdown: - breakdown.entities[entity_value].api_key_breakdown[record.api_key] = KeyMetricWithMetadata( - metrics=SpendMetrics(), - metadata=KeyMetadata( - key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), - team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), - ), + if not is_ptu_sentinel: + if record.api_key not in breakdown.entities[entity_value].api_key_breakdown: + breakdown.entities[entity_value].api_key_breakdown[record.api_key] = KeyMetricWithMetadata( + metrics=SpendMetrics(), + metadata=KeyMetadata( + key_alias=api_key_metadata.get(record.api_key, {}).get("key_alias", None), + team_id=api_key_metadata.get(record.api_key, {}).get("team_id", None), + ), + ) + breakdown.entities[entity_value].api_key_breakdown[record.api_key].metrics = update_metrics( + breakdown.entities[entity_value].api_key_breakdown[record.api_key].metrics, + record, ) - breakdown.entities[entity_value].api_key_breakdown[record.api_key].metrics = update_metrics( - breakdown.entities[entity_value].api_key_breakdown[record.api_key].metrics, - record, - ) return breakdown @@ -401,6 +418,14 @@ def _build_aggregated_sql_query( if pg_table is None: raise ValueError(f"Unknown table name: {table_name}") + # Only LiteLLM_DailyTeamSpend carries ptu_flat_cost today. Other daily tables + # emit a constant zero so the response shape (SpendMetrics.flat_cost) stays + # uniform for every entity. + has_ptu_flat_cost = table_name == "litellm_dailyteamspend" + ptu_flat_cost_select = ( + "SUM(ptu_flat_cost)::float AS ptu_flat_cost" if has_ptu_flat_cost else "0::float AS ptu_flat_cost" + ) + adjusted_start, adjusted_end = _adjust_dates_for_timezone(start_date, end_date, timezone_offset_minutes) sql_conditions: List[str] = [] @@ -469,6 +494,7 @@ def _build_aggregated_sql_query( custom_llm_provider, mcp_namespaced_tool_name, endpoint) AS group_level, SUM(spend)::float AS spend, + {ptu_flat_cost_select}, SUM(prompt_tokens)::bigint AS prompt_tokens, SUM(completion_tokens)::bigint AS completion_tokens, SUM(cache_read_input_tokens)::bigint AS cache_read_input_tokens, @@ -560,7 +586,9 @@ async def _aggregate_spend_records( The per-row loop is offloaded to a worker thread via asyncio.to_thread so a large result set doesn't peg the event loop. """ - api_keys: Set[str] = {record.api_key for record in records if record.api_key} + api_keys: Set[str] = { + record.api_key for record in records if record.api_key and record.api_key != PTU_SENTINEL_API_KEY + } api_key_metadata: Dict[str, Dict[str, Any]] = {} if api_keys: @@ -607,6 +635,7 @@ def _record_to_spend_metrics(record: Any) -> SpendMetrics: completion_tokens = record.completion_tokens or 0 return SpendMetrics( spend=record.spend or 0.0, + flat_cost=getattr(record, "ptu_flat_cost", None) or 0.0, prompt_tokens=prompt_tokens, completion_tokens=completion_tokens, total_tokens=prompt_tokens + completion_tokens, @@ -669,6 +698,10 @@ def _aggregate_grouping_sets_records_sync( for record in records: level = record.group_level metrics = _record_to_spend_metrics(record) + # Sentinel PTU rows contribute to grand-total / per-day / per-model / + # per-provider / per-endpoint buckets, but must never appear in any + # api_key or api_key_breakdown map. + real_api_key = record.api_key and record.api_key != PTU_SENTINEL_API_KEY if level == _GROUP_GRAND_TOTAL: total_metrics = metrics @@ -681,7 +714,7 @@ def _aggregate_grouping_sets_records_sync( breakdown = ensure_date(record.date)["breakdown"] if level == _GROUP_DATE_API_KEY: - if record.api_key: + if real_api_key: breakdown.api_keys[record.api_key] = KeyMetricWithMetadata( metrics=metrics, metadata=_key_metadata(api_key_metadata, record.api_key), @@ -690,13 +723,13 @@ def _aggregate_grouping_sets_records_sync( if record.model: assign_metric_with_metadata(breakdown.models, record.model, metrics) elif level == _GROUP_DATE_MODEL_API_KEY: - if record.model and record.api_key: + if record.model and real_api_key: assign_api_key_breakdown(breakdown.models, record.model, record.api_key, metrics) elif level == _GROUP_DATE_MODEL_GROUP: if record.model_group: assign_metric_with_metadata(breakdown.model_groups, record.model_group, metrics) elif level == _GROUP_DATE_MODEL_GROUP_API_KEY: - if record.model_group and record.api_key: + if record.model_group and real_api_key: assign_api_key_breakdown( breakdown.model_groups, record.model_group, @@ -707,14 +740,14 @@ def _aggregate_grouping_sets_records_sync( provider = record.custom_llm_provider or "unknown" assign_metric_with_metadata(breakdown.providers, provider, metrics) elif level == _GROUP_DATE_PROVIDER_API_KEY: - if record.api_key: + if real_api_key: provider = record.custom_llm_provider or "unknown" assign_api_key_breakdown(breakdown.providers, provider, record.api_key, metrics) elif level == _GROUP_DATE_MCP: if record.mcp_namespaced_tool_name: assign_metric_with_metadata(breakdown.mcp_servers, record.mcp_namespaced_tool_name, metrics) elif level == _GROUP_DATE_MCP_API_KEY: - if record.mcp_namespaced_tool_name and record.api_key: + if record.mcp_namespaced_tool_name and real_api_key: assign_api_key_breakdown( breakdown.mcp_servers, record.mcp_namespaced_tool_name, @@ -725,7 +758,7 @@ def _aggregate_grouping_sets_records_sync( if record.endpoint: assign_metric_with_metadata(breakdown.endpoints, record.endpoint, metrics) elif level == _GROUP_DATE_ENDPOINT_API_KEY: - if record.endpoint and record.api_key: + if record.endpoint and real_api_key: assign_api_key_breakdown(breakdown.endpoints, record.endpoint, record.api_key, metrics) results = [ @@ -747,7 +780,7 @@ async def _aggregate_grouping_sets_records( records: List[Any], ) -> Dict[str, Any]: """Async wrapper: fetch api_key_metadata, then dispatch on a worker thread.""" - api_keys: Set[str] = {r.api_key for r in records if r.api_key} + api_keys: Set[str] = {r.api_key for r in records if r.api_key and r.api_key != PTU_SENTINEL_API_KEY} api_key_metadata: Dict[str, Dict[str, Any]] = {} if api_keys: @@ -854,6 +887,7 @@ async def get_daily_activity( results=aggregated["results"], metadata=DailySpendMetadata( total_spend=metadata_metrics.spend, + total_flat_cost=metadata_metrics.flat_cost, total_prompt_tokens=metadata_metrics.prompt_tokens, total_completion_tokens=metadata_metrics.completion_tokens, total_tokens=metadata_metrics.total_tokens, @@ -940,6 +974,7 @@ async def get_daily_activity_aggregated( results=aggregated["results"], metadata=DailySpendMetadata( total_spend=aggregated["totals"].spend, + total_flat_cost=aggregated["totals"].flat_cost, total_prompt_tokens=aggregated["totals"].prompt_tokens, total_completion_tokens=aggregated["totals"].completion_tokens, total_tokens=aggregated["totals"].total_tokens, diff --git a/litellm/proxy/spend_tracking/ptu_reservation_rollup.py b/litellm/proxy/spend_tracking/ptu_reservation_rollup.py index f5a2061c969..69de5b157a4 100644 --- a/litellm/proxy/spend_tracking/ptu_reservation_rollup.py +++ b/litellm/proxy/spend_tracking/ptu_reservation_rollup.py @@ -12,11 +12,9 @@ from datetime import date, datetime, time, timedelta, timezone from typing import Any from litellm._logging import verbose_proxy_logger +from litellm.constants import PTU_ROLLUP_JOB_ID, PTU_SENTINEL_API_KEY from litellm.repositories.ptu_reservation_repository import PTUReservationRepository -PTU_SENTINEL_API_KEY = "__ptu_reservation__" -PTU_ROLLUP_JOB_ID = "ptu_reservation_rollup_job" - @dataclass(frozen=True, slots=True) class RollupResult: diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index b57d39f4c1a..0ae3dc51d02 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -18,6 +18,7 @@ class GroupByDimension(str, Enum): class SpendMetrics(BaseModel): spend: float = Field(default=0.0) + flat_cost: float = Field(default=0.0) prompt_tokens: int = Field(default=0) completion_tokens: int = Field(default=0) cache_read_input_tokens: int = Field(default=0) @@ -71,6 +72,7 @@ class DailySpendData(BaseModel): class DailySpendMetadata(BaseModel): total_spend: float = Field(default=0.0) + total_flat_cost: float = Field(default=0.0) total_prompt_tokens: int = Field(default=0) total_completion_tokens: int = Field(default=0) total_tokens: int = Field(default=0) diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index b882090e8f1..2b28211b6d2 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -936,3 +936,300 @@ def test_update_metrics_handles_none_values(): assert metrics.failed_requests == 0 assert metrics.cache_read_input_tokens == 0 assert metrics.cache_creation_input_tokens == 0 + + +# --------------------------------------------------------------------------- +# Stage 3: ptu_flat_cost surfacing on team daily activity read path +# --------------------------------------------------------------------------- + +from litellm.constants import PTU_SENTINEL_API_KEY # noqa: E402 +from litellm.proxy.management_endpoints.common_daily_activity import ( # noqa: E402 + update_breakdown_metrics, +) +from litellm.types.proxy.management_endpoints.common_daily_activity import ( # noqa: E402 + BreakdownMetrics, +) + + +def _team_row( + *, + date="2026-08-01", + api_key="sk-real", + model="gpt-4", + model_group=None, + custom_llm_provider="azure", + endpoint=None, + mcp_namespaced_tool_name=None, + spend=0.0, + ptu_flat_cost=0.0, + prompt_tokens=0, + completion_tokens=0, + cache_read_input_tokens=0, + cache_creation_input_tokens=0, + api_requests=0, + successful_requests=0, + failed_requests=0, + team_id="team_x", +): + return SimpleNamespace( + date=date, + api_key=api_key, + model=model, + model_group=model_group, + custom_llm_provider=custom_llm_provider, + endpoint=endpoint, + mcp_namespaced_tool_name=mcp_namespaced_tool_name, + spend=spend, + ptu_flat_cost=ptu_flat_cost, + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + cache_read_input_tokens=cache_read_input_tokens, + cache_creation_input_tokens=cache_creation_input_tokens, + api_requests=api_requests, + successful_requests=successful_requests, + failed_requests=failed_requests, + team_id=team_id, + ) + + +def test_spend_metrics_flat_cost_defaults_zero(): + assert SpendMetrics().flat_cost == 0.0 + + +def test_update_metrics_adds_ptu_flat_cost(): + metrics = SpendMetrics() + update_metrics(metrics, _team_row(ptu_flat_cost=6.45)) + update_metrics(metrics, _team_row(ptu_flat_cost=3.55)) + assert metrics.flat_cost == pytest.approx(10.0) + + +def test_update_metrics_ignores_missing_ptu_flat_cost_attr(): + """User/org/tag daily rows don't carry ptu_flat_cost — must not raise.""" + row_without_field = SimpleNamespace( + spend=1.0, + prompt_tokens=0, + completion_tokens=0, + cache_read_input_tokens=0, + cache_creation_input_tokens=0, + api_requests=0, + successful_requests=0, + failed_requests=0, + ) + metrics = SpendMetrics() + update_metrics(metrics, row_without_field) + assert metrics.spend == pytest.approx(1.0) + assert metrics.flat_cost == 0.0 + + +def test_update_metrics_treats_none_ptu_flat_cost_as_zero(): + row = _team_row(ptu_flat_cost=None) + metrics = SpendMetrics() + update_metrics(metrics, row) + assert metrics.flat_cost == 0.0 + + +def test_record_to_spend_metrics_reads_ptu_flat_cost(): + row = _team_row(ptu_flat_cost=42.0) + m = _record_to_spend_metrics(row) + assert m.flat_cost == pytest.approx(42.0) + + +def test_record_to_spend_metrics_defaults_ptu_flat_cost_when_absent(): + row_without = SimpleNamespace( + spend=1.0, + prompt_tokens=0, + completion_tokens=0, + cache_read_input_tokens=0, + cache_creation_input_tokens=0, + api_requests=0, + successful_requests=0, + failed_requests=0, + ) + m = _record_to_spend_metrics(row_without) + assert m.flat_cost == 0.0 + + +def test_sentinel_row_skipped_from_top_level_api_keys_breakdown(): + breakdown = BreakdownMetrics() + sentinel_row = _team_row( + api_key=PTU_SENTINEL_API_KEY, + ptu_flat_cost=6.45, + custom_llm_provider=None, + ) + update_breakdown_metrics(breakdown, sentinel_row, {}, {}, {}) + assert PTU_SENTINEL_API_KEY not in breakdown.api_keys + assert breakdown.api_keys == {} + + +def test_sentinel_row_contributes_to_model_breakdown_flat_cost(): + breakdown = BreakdownMetrics() + sentinel_row = _team_row( + api_key=PTU_SENTINEL_API_KEY, + model="gpt-4", + ptu_flat_cost=6.45, + ) + update_breakdown_metrics(breakdown, sentinel_row, {}, {}, {}) + assert "gpt-4" in breakdown.models + assert breakdown.models["gpt-4"].metrics.flat_cost == pytest.approx(6.45) + # And it must not leak into api_key_breakdown under the model + assert PTU_SENTINEL_API_KEY not in breakdown.models["gpt-4"].api_key_breakdown + + +def test_real_key_and_sentinel_row_share_a_model_bucket(): + breakdown = BreakdownMetrics() + real_row = _team_row(api_key="sk-real", model="gpt-4", spend=1.5) + sentinel_row = _team_row(api_key=PTU_SENTINEL_API_KEY, model="gpt-4", ptu_flat_cost=6.45) + update_breakdown_metrics(breakdown, real_row, {}, {}, {}) + update_breakdown_metrics(breakdown, sentinel_row, {}, {}, {}) + + model_bucket = breakdown.models["gpt-4"] + assert model_bucket.metrics.spend == pytest.approx(1.5) + assert model_bucket.metrics.flat_cost == pytest.approx(6.45) + + # api_keys only surfaces the real key + assert list(breakdown.api_keys.keys()) == ["sk-real"] + # and the api_key_breakdown under the model also only holds the real key + assert list(model_bucket.api_key_breakdown.keys()) == ["sk-real"] + + +def test_sentinel_row_contributes_to_provider_and_endpoint_breakdowns(): + breakdown = BreakdownMetrics() + sentinel_row = _team_row( + api_key=PTU_SENTINEL_API_KEY, + model="gpt-4", + custom_llm_provider="azure", + endpoint="/v1/chat/completions", + ptu_flat_cost=6.45, + ) + update_breakdown_metrics(breakdown, sentinel_row, {}, {}, {}) + assert breakdown.providers["azure"].metrics.flat_cost == pytest.approx(6.45) + assert PTU_SENTINEL_API_KEY not in breakdown.providers["azure"].api_key_breakdown + assert breakdown.endpoints["/v1/chat/completions"].metrics.flat_cost == pytest.approx(6.45) + assert PTU_SENTINEL_API_KEY not in breakdown.endpoints["/v1/chat/completions"].api_key_breakdown + + +def test_sentinel_row_contributes_to_entity_breakdown(): + breakdown = BreakdownMetrics() + sentinel_row = _team_row( + api_key=PTU_SENTINEL_API_KEY, + team_id="team_x", + ptu_flat_cost=6.45, + ) + update_breakdown_metrics( + breakdown, + sentinel_row, + {}, + {}, + {}, + entity_id_field="team_id", + ) + assert breakdown.entities["team_x"].metrics.flat_cost == pytest.approx(6.45) + assert PTU_SENTINEL_API_KEY not in breakdown.entities["team_x"].api_key_breakdown + + +def test_get_api_key_metadata_excludes_sentinel_from_lookup(): + """The sentinel api_key is not a real hashed token; skip the Prisma lookup for it.""" + from litellm.proxy.management_endpoints.common_daily_activity import ( + _aggregate_spend_records, + ) + import asyncio + + mock_prisma = MagicMock() + mock_vt_table = MagicMock() + mock_vt_table.find_many = AsyncMock(return_value=[]) + mock_deleted_table = MagicMock() + mock_deleted_table.find_many = AsyncMock(return_value=[]) + mock_prisma.db = MagicMock() + mock_prisma.db.litellm_verificationtoken = mock_vt_table + mock_prisma.db.litellm_deletedverificationtoken = mock_deleted_table + + real_row = _team_row(api_key="sk-real") + sentinel_row = _team_row(api_key=PTU_SENTINEL_API_KEY, ptu_flat_cost=6.45) + + asyncio.run( + _aggregate_spend_records( + prisma_client=mock_prisma, + records=[real_row, sentinel_row], + entity_id_field="team_id", + entity_metadata_field=None, + ) + ) + + mock_vt_table.find_many.assert_called_once() + where = mock_vt_table.find_many.await_args.kwargs["where"] + queried = set(where["token"]["in"]) + assert "sk-real" in queried + assert PTU_SENTINEL_API_KEY not in queried + + +def test_build_aggregated_sql_query_selects_ptu_flat_cost_only_for_team_table(): + sql_team, _ = _build_aggregated_sql_query( + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=None, + start_date="2026-08-01", + end_date="2026-08-31", + model=None, + api_key=None, + ) + assert "SUM(ptu_flat_cost)::float AS ptu_flat_cost" in sql_team + + sql_user, _ = _build_aggregated_sql_query( + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=None, + start_date="2026-08-01", + end_date="2026-08-31", + model=None, + api_key=None, + ) + assert "SUM(ptu_flat_cost)" not in sql_user + assert "0::float AS ptu_flat_cost" in sql_user + + +@pytest.mark.asyncio +async def test_get_daily_activity_returns_total_flat_cost_for_team(): + """End-to-end: seed team daily rows (one real + one sentinel), assert response shape.""" + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + + mock_table = MagicMock() + mock_table.count = AsyncMock(return_value=2) + mock_table.find_many = AsyncMock( + return_value=[ + _team_row(api_key="sk-real", spend=1.5, ptu_flat_cost=0.0), + _team_row(api_key=PTU_SENTINEL_API_KEY, spend=0.0, ptu_flat_cost=6.45), + ] + ) + mock_prisma.db.litellm_dailyteamspend = mock_table + + mock_vt_table = MagicMock() + mock_vt_table.find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_verificationtoken = mock_vt_table + mock_deleted_table = MagicMock() + mock_deleted_table.find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_deletedverificationtoken = mock_deleted_table + + result = await get_daily_activity( + prisma_client=mock_prisma, + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=None, + entity_metadata_field=None, + start_date="2026-08-01", + end_date="2026-08-01", + model=None, + api_key=None, + page=1, + page_size=10, + ) + + assert result.metadata.total_spend == pytest.approx(1.5) + assert result.metadata.total_flat_cost == pytest.approx(6.45) + assert len(result.results) == 1 + day = result.results[0] + assert day.metrics.spend == pytest.approx(1.5) + assert day.metrics.flat_cost == pytest.approx(6.45) + # api_keys breakdown must not contain the sentinel + assert PTU_SENTINEL_API_KEY not in day.breakdown.api_keys + assert "sk-real" in day.breakdown.api_keys diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index aba3a85eb7c..27008e09296 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -23332,6 +23332,11 @@ export interface components { * @default 0 */ total_failed_requests: number; + /** + * Total Flat Cost + * @default 0 + */ + total_flat_cost: number; /** * Total Pages * @default 1 @@ -31022,6 +31027,11 @@ export interface components { * @default 0 */ failed_requests: number; + /** + * Flat Cost + * @default 0 + */ + flat_cost: number; /** * Prompt Tokens * @default 0