Repository navigation
feat(spend): add net auto-router savings to the cost-optimization dashboard #35521
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
a028f6c
c6e0f1b
721790d
cfc4f13
1b54ea1
fa80e64
6e17303
acc10e8
ec72930
8d0283c
fe4cb35
a08e5dc
22795c2
e5e0396
c4695a4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| -- AlterTable | ||
| ALTER TABLE "LiteLLM_DailyUserSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; | ||
|
|
||
| -- AlterTable | ||
| ALTER TABLE "LiteLLM_DailyOrganizationSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; | ||
|
|
||
| -- AlterTable | ||
| ALTER TABLE "LiteLLM_DailyEndUserSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; | ||
|
|
||
| -- AlterTable | ||
| ALTER TABLE "LiteLLM_DailyAgentSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; | ||
|
|
||
| -- AlterTable | ||
| ALTER TABLE "LiteLLM_DailyTeamSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; | ||
|
|
||
| -- AlterTable | ||
| ALTER TABLE "LiteLLM_DailyTagSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1545,6 +1545,30 @@ async def _update_daily_spend( | |
| # Get the table dynamically | ||
| table = getattr(batcher, table_name) | ||
|
|
||
| # Additive metrics that older queued rows may omit; one | ||
| # enumeration feeds both the create and the increment below | ||
| optional_metrics = { | ||
| field: value | ||
| for field, value in ( | ||
| ("cache_read_input_tokens", transaction.get("cache_read_input_tokens")), | ||
| ( | ||
| "cache_creation_input_tokens", | ||
| transaction.get("cache_creation_input_tokens"), | ||
| ), | ||
| ("compression_saved_tokens", transaction.get("compression_saved_tokens")), | ||
| ( | ||
| "compression_savings_spend", | ||
| transaction.get("compression_savings_spend"), | ||
| ), | ||
| ( | ||
| "prompt_caching_savings_spend", | ||
| transaction.get("prompt_caching_savings_spend"), | ||
| ), | ||
| ("autorouter_savings_spend", transaction.get("autorouter_savings_spend")), | ||
| ) | ||
| if value is not None | ||
| } | ||
|
|
||
| # Common data structure for both create and update | ||
| common_data = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here. I would approach this with all
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same change here: |
||
| entity_id_field: entity_id, | ||
|
|
@@ -1561,30 +1585,9 @@ async def _update_daily_spend( | |
| "api_requests": transaction["api_requests"], | ||
| "successful_requests": transaction["successful_requests"], | ||
| "failed_requests": transaction["failed_requests"], | ||
| **optional_metrics, | ||
| } | ||
|
|
||
| # Add cache-related fields if they exist | ||
| if "cache_read_input_tokens" in transaction: | ||
| common_data["cache_read_input_tokens"] = transaction.get( | ||
| "cache_read_input_tokens", 0 | ||
| ) | ||
| if "cache_creation_input_tokens" in transaction: | ||
| common_data["cache_creation_input_tokens"] = transaction.get( | ||
| "cache_creation_input_tokens", 0 | ||
| ) | ||
| if "compression_saved_tokens" in transaction: | ||
| common_data["compression_saved_tokens"] = transaction.get( | ||
| "compression_saved_tokens", 0 | ||
| ) | ||
| if "compression_savings_spend" in transaction: | ||
| common_data["compression_savings_spend"] = transaction.get( | ||
| "compression_savings_spend", 0 | ||
| ) | ||
| if "prompt_caching_savings_spend" in transaction: | ||
| common_data["prompt_caching_savings_spend"] = transaction.get( | ||
| "prompt_caching_savings_spend", 0 | ||
| ) | ||
|
|
||
| if entity_type == "tag" and "request_id" in transaction: | ||
| common_data["request_id"] = transaction.get("request_id") | ||
|
|
||
|
|
@@ -1596,30 +1599,9 @@ async def _update_daily_spend( | |
| "api_requests": {"increment": transaction["api_requests"]}, | ||
| "successful_requests": {"increment": transaction["successful_requests"]}, | ||
| "failed_requests": {"increment": transaction["failed_requests"]}, | ||
| **{field: {"increment": value} for field, value in optional_metrics.items()}, | ||
| } | ||
|
|
||
| # Add cache-related fields to update if they exist | ||
| if "cache_read_input_tokens" in transaction: | ||
| update_data["cache_read_input_tokens"] = { | ||
| "increment": transaction.get("cache_read_input_tokens", 0) | ||
| } | ||
| if "cache_creation_input_tokens" in transaction: | ||
| update_data["cache_creation_input_tokens"] = { | ||
| "increment": transaction.get("cache_creation_input_tokens", 0) | ||
| } | ||
| if "compression_saved_tokens" in transaction: | ||
| update_data["compression_saved_tokens"] = { | ||
| "increment": transaction.get("compression_saved_tokens", 0) | ||
| } | ||
| if "compression_savings_spend" in transaction: | ||
| update_data["compression_savings_spend"] = { | ||
| "increment": transaction.get("compression_savings_spend", 0) | ||
| } | ||
| if "prompt_caching_savings_spend" in transaction: | ||
| update_data["prompt_caching_savings_spend"] = { | ||
| "increment": transaction.get("prompt_caching_savings_spend", 0) | ||
| } | ||
|
|
||
| if entity_type == "tag" and "request_id" in transaction: | ||
| update_data["request_id"] = transaction.get("request_id") | ||
|
|
||
|
|
@@ -1875,6 +1857,10 @@ async def _common_add_spend_log_transaction_to_daily_transaction( | |
| custom_llm_provider=payload.get("custom_llm_provider", None), | ||
| compression_saved_tokens=compression_saved_tokens, | ||
| cache_read_input_tokens=cache_read_input_tokens, | ||
| routing_decision=_metadata.get("routing_decision"), | ||
| model_map_information=_metadata.get("model_map_information"), | ||
| model_id=payload.get("model_id"), | ||
| usage_object=usage_obj, | ||
| ) | ||
|
|
||
| daily_transaction = BaseDailySpendTransaction( | ||
|
|
@@ -1896,6 +1882,7 @@ async def _common_add_spend_log_transaction_to_daily_transaction( | |
| compression_saved_tokens=compression_saved_tokens, | ||
| compression_savings_spend=savings_spend.compression, | ||
| prompt_caching_savings_spend=savings_spend.prompt_caching, | ||
| autorouter_savings_spend=savings_spend.autorouter, | ||
| ) | ||
| return daily_transaction | ||
| except Exception as e: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,6 +95,9 @@ def compression_savings_spend(self) -> float: ... | |
| @property | ||
| def prompt_caching_savings_spend(self) -> float: ... | ||
|
|
||
| @property | ||
| def autorouter_savings_spend(self) -> float: ... | ||
|
|
||
| @property | ||
| def api_requests(self) -> int: ... | ||
|
|
||
|
|
@@ -135,6 +138,7 @@ class _GroupingSetsRow(SimpleNamespace): | |
| compression_saved_tokens: int | None | ||
| compression_savings_spend: float | None | ||
| prompt_caching_savings_spend: float | None | ||
| autorouter_savings_spend: float | None | ||
| api_requests: int | None | ||
| successful_requests: int | None | ||
| failed_requests: int | None | ||
|
|
@@ -158,6 +162,7 @@ def update_metrics(existing_metrics: SpendMetrics, record: DailySpendRecord) -> | |
| existing_metrics.compression_saved_tokens += record.compression_saved_tokens or 0 | ||
| existing_metrics.compression_savings_spend += record.compression_savings_spend or 0 | ||
| existing_metrics.prompt_caching_savings_spend += record.prompt_caching_savings_spend or 0 | ||
| existing_metrics.autorouter_savings_spend += record.autorouter_savings_spend or 0 | ||
| existing_metrics.api_requests += record.api_requests or 0 | ||
| existing_metrics.successful_requests += record.successful_requests or 0 | ||
| existing_metrics.failed_requests += record.failed_requests or 0 | ||
|
|
@@ -590,6 +595,7 @@ def _build_aggregated_sql_query( | |
| SUM(compression_saved_tokens)::bigint AS compression_saved_tokens, | ||
| SUM(compression_savings_spend)::float AS compression_savings_spend, | ||
| SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend, | ||
| SUM(autorouter_savings_spend)::float AS autorouter_savings_spend, | ||
| SUM(api_requests)::bigint AS api_requests, | ||
| SUM(successful_requests)::bigint AS successful_requests, | ||
| SUM(failed_requests)::bigint AS failed_requests | ||
|
|
@@ -732,6 +738,7 @@ def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics: | |
| compression_saved_tokens=record.compression_saved_tokens or 0, | ||
| compression_savings_spend=record.compression_savings_spend or 0, | ||
| prompt_caching_savings_spend=record.prompt_caching_savings_spend or 0, | ||
| autorouter_savings_spend=record.autorouter_savings_spend or 0, | ||
| api_requests=record.api_requests or 0, | ||
| successful_requests=record.successful_requests or 0, | ||
| failed_requests=record.failed_requests or 0, | ||
|
|
@@ -986,6 +993,7 @@ async def get_daily_activity( | |
| total_compression_saved_tokens=metadata_metrics.compression_saved_tokens, | ||
| total_compression_savings_spend=metadata_metrics.compression_savings_spend, | ||
| total_prompt_caching_savings_spend=metadata_metrics.prompt_caching_savings_spend, | ||
| total_autorouter_savings_spend=metadata_metrics.autorouter_savings_spend, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Low: Deployment pricing disclosure This returns savings calculated from deployment-specific pricing overrides through
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declining this one, with the code rather than an argument.
Pricing is published directly, not just inferable. so a caller reads the rates rather than solving for them, and the dashboard depends on that to show what models cost. Treating the derived figure as sensitive while the endpoint states the inputs would be a posture change for the proxy, not a fix to this PR. One residual worth naming rather than hiding: the baseline is the hardest tier's deployment, which a given caller may not be entitled to enumerate through
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call. |
||
| page=page, | ||
| total_pages=-(-total_count // page_size), # Ceiling division | ||
| has_more=(page * page_size) < total_count, | ||
|
|
@@ -1075,6 +1083,7 @@ async def get_daily_activity_aggregated( | |
| total_compression_saved_tokens=aggregated["totals"].compression_saved_tokens, | ||
| total_compression_savings_spend=aggregated["totals"].compression_savings_spend, | ||
| total_prompt_caching_savings_spend=aggregated["totals"].prompt_caching_savings_spend, | ||
| total_autorouter_savings_spend=aggregated["totals"].autorouter_savings_spend, | ||
| page=1, | ||
| total_pages=1, | ||
| has_more=False, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I would wrap in a MappingProxyType if you're not mutating it. That way, it's a type error if you try to mutate it unexpectedly
Strongly prefer constructing the dict in one place all at once and marking it as unchangeable, instead of slowly adding to it over time, as the latter can lead to a whole class of "wait, did someone mutate this out from under me?" bugs. AFAICT, you've done pt. 1 here but not pt. 2
Even better if they can be frozen
slots=Truedataclasses, because of the typing guarantees but that's another discussionThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Took the one-shot half, skipped the freeze, because prisma rejects it.
Both payloads go straight to
table.upsert, and prisma's builder branches onisinstance(value, dict)to tell a nested node from a scalar (builder.py:851,903). A mappingproxy is a Mapping but not a dict, so it falls to the serializer:That would raise inside the batch upsert, where the surrounding
except Exception as batch_errorlogs it and moves on, so daily rollups would quietly stop updating. Same blocker kills the frozen-dataclass version, plus it would need ato_dict()at the prisma boundary and hand the guarantee straight back.Your pt. 2 was right though, and worse than the nit suggested:
common_dataandupdate_datawere both appended to after construction (request_idfor tag rows, thenendpointunconditionally). The conditional key is now resolved to a spreadable value first, so both are single literals and the tag branch appears once instead of twice. Verified the payloads are byte-identical across user/tag and both endpoint shapes.optional_metricsis the one that could be wrapped safely, since it is only ever spread. Happy to if you want the marker somewhere, though it is already built in one shot and never touched.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean "hand the guarantee straight back"? I understand we might need to convert to dict at the prisma boundary, but I would argue that doesn't defeat the point of frozen dataclasses. All the dangerous stuff can be put in simple seam functions (e.g., see the repository classes in our codebase) while core heavy-duty business logic is stateless, pure, and strongly typed