-
-
Notifications
You must be signed in to change notification settings - Fork 11k
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 all 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 |
|---|---|---|
|
|
@@ -12,7 +12,9 @@ | |
| import random | ||
| import time | ||
| import traceback | ||
| from collections.abc import Mapping | ||
| from datetime import datetime, timedelta, timezone | ||
| from types import MappingProxyType | ||
| from typing import ( | ||
| TYPE_CHECKING, | ||
| Any, | ||
|
|
@@ -68,6 +70,26 @@ | |
| ProxyLogging = Any | ||
|
|
||
|
|
||
| # Only tag rows carry a request_id, so the other entity types spread nothing. Built | ||
| # once here rather than as an empty literal per transaction, and read-only so it cannot | ||
| # be filled in by accident from one of the call sites that spreads it. | ||
| _NO_TAG_REQUEST_ID: Mapping[str, Any] = MappingProxyType({}) | ||
|
|
||
|
|
||
| def _get_llm_router(): | ||
| """The proxy's router, or None outside a running proxy. | ||
|
|
||
| Injected rather than imported where it is used, so the savings computation stays | ||
| a pure function of its arguments and the caller owns where the router comes from. | ||
| """ | ||
| try: | ||
| from litellm.proxy.proxy_server import llm_router | ||
|
|
||
| return llm_router | ||
| except Exception: # noqa: BLE001 # no proxy in scope; savings degrade to zero | ||
| return None | ||
|
|
||
|
|
||
| def _extract_cache_read_tokens(usage_obj: dict) -> int: | ||
| """ | ||
| Anthropic: top-level cache_read_input_tokens field. | ||
|
|
@@ -1545,6 +1567,40 @@ 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 | ||
| } | ||
|
|
||
| # Only tag rows carry a request_id. Resolved to a spreadable | ||
| # value here so both payloads are built in one shot: a dict | ||
| # appended to after construction is one nobody can reason about | ||
| # by reading its literal. | ||
| tag_request_id: Mapping[str, Any] = ( | ||
| MappingProxyType({"request_id": transaction["request_id"]}) | ||
| if entity_type == "tag" and "request_id" in transaction | ||
| else _NO_TAG_REQUEST_ID | ||
| ) | ||
|
|
||
| # 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,71 +1617,23 @@ async def _update_daily_spend( | |
| "api_requests": transaction["api_requests"], | ||
| "successful_requests": transaction["successful_requests"], | ||
| "failed_requests": transaction["failed_requests"], | ||
| **optional_metrics, | ||
| **tag_request_id, | ||
| } | ||
|
|
||
| # 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") | ||
|
|
||
| # Create update data structure | ||
| update_data = { | ||
| "prompt_tokens": {"increment": transaction["prompt_tokens"]}, | ||
| "completion_tokens": {"increment": transaction["completion_tokens"]}, | ||
| "spend": {"increment": transaction["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()}, | ||
| # An existing row predating the endpoint column gets it filled in here | ||
| "endpoint": transaction.get("endpoint") or "", | ||
| **tag_request_id, | ||
| } | ||
|
|
||
| # 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") | ||
|
|
||
| # Add endpoint to update_data so existing rows get their endpoint field updated | ||
| update_data["endpoint"] = transaction.get("endpoint") or "" | ||
|
|
||
| table.upsert( | ||
| where=where_clause, | ||
| data={ | ||
|
|
@@ -1875,6 +1883,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_id=payload.get("model_id"), | ||
| llm_router=_get_llm_router(), | ||
| usage_object=usage_obj, | ||
| ) | ||
|
|
||
| daily_transaction = BaseDailySpendTransaction( | ||
|
|
@@ -1896,6 +1908,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: | ||
|
|
||
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