Repository navigation
fix(spend-tracking): keep batch spend keys joinable after v1.99 provenance gate - #39568
mateo-berri merged 21 commits into
Conversation
…nance gate Batch cost attribution and the legacy queue endpoint already store the VerificationToken hash in user_api_key, but omitted user_api_key_hash. Since v1.99 the spend-log writer re-hashes any key without that provenance flag, so DailyUserSpend.api_key no longer joins VerificationToken and Usage shows key-hash-... rows with null api_key_alias / user_email. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
|
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Callback log replay also omitted user_api_key_hash, so it could double-hash spend rows the same way batch costing did. On the read path, Usage key metadata now reverse-hashes orphaned DailyUserSpend.api_key values against VerificationToken and falls back to SpendLogs metadata so historical dirty rows show their api_key_alias again instead of key-hash-... Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
…ocus Extract the Usage reverse-hash / SpendLogs alias recovery into a shared helper and apply it when CloudZero and Focus export DailyUserSpend rows, so BI pulls get api_key_alias back for historical v1.99 double-hashed keys instead of null. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
…in spend logs Recovery now resolves the key owner's email from UserTable via the recovered token user_id, and SpendLogsMetadata keeps user_api_key_user_email so new batch/export consumers see email without a separate user join. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
Lint CI failed because ruff format splits the Set alias import and collapses a couple of long lines. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
The Usage recovery path was adding four BLE001 hits and failing the strict-rule budget. Soft-fail only on PrismaError so a down token table still falls through to SpendLogs. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
The Python 3.10 smoke check imports the proxy without prisma, so PrismaError is loaded only inside the DB helper. Recovery now returns frozen mappings and ReadOnly TypedDict fields so the type-discipline budget stays put. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
Recovery already resolved the owner email for double-hashed spend keys, then Key Activity dropped it. The Usage payload now carries user_email and the key label falls back to that email before key-hash-... Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
Key Activity charts already fell back to user_email. The top-keys tables and usage export still printed '-' or a truncated hash. They now use the same alias-then-email label. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
…with email recovery Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
Required lint failed ruff format on the ternary that unwraps a polars DataFrame or list before alias recovery Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
|
@greptileai please rescore this PR now that required CI is green on tip 4db370e |
Greptile SummaryThe PR preserves virtual-key provenance on batch, queued, and callback-replayed spend writes, then recovers metadata for historical double-hashed rows
Confidence Score: 5/5The PR appears safe to merge No blocking failure remains
|
| Filename | Overview |
|---|---|
| litellm/proxy/spend_tracking/key_metadata_recovery.py | Adds shared active/deleted-key digest recovery and owner-email enrichment |
| litellm/proxy/management_endpoints/common_daily_activity.py | Integrates recovered key metadata and user email into daily activity breakdowns |
| enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py | Stamps the existing virtual-key hash as provenance for batch cost writes |
| litellm/integrations/cloudzero/database.py | Enriches CloudZero usage rows with recovered key metadata before frame construction |
| litellm/integrations/focus/database.py | Enriches Focus usage rows with recovered key metadata before frame construction |
| ui/litellm-dashboard/src/components/UsagePage/keyActivityLabel.ts | Centralizes key activity labels using alias, email, and hashed-key fallback data |
Reviews (4): Last reviewed commit: "Merge branch 'litellm_fix-batch-spend-ke..." | Re-trigger Greptile
Historical dirty spend on large installs was still unlabeled when the matching token sat past the first page. Keep scanning until the digest matches or the table ends. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
| wanted: AbstractSet[str], | ||
| ) -> Mapping[str, KeyMetadataDict]: | ||
| active_records: Final = await _db_or_empty( | ||
| lambda: VerificationTokenRepository(prisma_client).table.find_many(take=_MAX_DOUBLE_HASH_TOKEN_SCAN), |
There was a problem hiding this comment.
Low: Uncached token scans enable denial of service
An authenticated caller with a legacy double-hashed spend row can repeatedly query their daily activity and make each request fetch and hash up to 10,000 active records, followed by another 10,000 deleted records. Avoid performing this reverse scan on the request path; backfill the affected rows or use a bounded, persistent digest lookup with positive and negative caching.
There was a problem hiding this comment.
We recover on read so we do not rewrite spend rows. The scan stops when the digest matches and only runs on join misses.
PR overviewThis pull request updates spend-tracking key metadata recovery so batch spend keys remain joinable after the v1.99 provenance gate, including for legacy double-hashed spend rows. One denial-of-service concern remains: an authenticated caller with affected legacy data can repeatedly trigger large uncached scans and hashing work during daily activity queries. Exploitation requires a legacy double-hashed row, but repeated requests could impose substantial database and CPU load. Open issues (1)
Fixed/addressed: 0 · PR risk: 4/10 |
|
@greptileai please rescore after the pagination fix. Recovery now reads every token page until the digest matches. |
…/litellm into litellm_fix-batch-spend-key-double-hash-bcae
…d of paging token tables
|
bugbot run |
Export rows already join user_email from DailyUserSpend.user_id. Recovered key-owner email must not replace that when only the alias join missed. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
|
Bugbot: export recovery was overwriting spend-user email. Alias still fills; existing email now stays. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Recovered export rows break DataFrame build
_row_with_recovered_fieldsnow returns a plain dict so polars.DataFrame consumes it as a dict record like the untouched to_dicts() rows.
- ✅ Fixed: Export recovery scans on missing email
- The
fill_missing_api_key_aliasestrigger now only fires on missing alias, so team/service-key rows with no user_email no longer force a full-table SHA-256 scan.
- The
Or push these changes by commenting:
@cursor push 527a8a930f
Preview (527a8a930f)
diff --git a/litellm/proxy/spend_tracking/key_metadata_recovery.py b/litellm/proxy/spend_tracking/key_metadata_recovery.py
--- a/litellm/proxy/spend_tracking/key_metadata_recovery.py
+++ b/litellm/proxy/spend_tracking/key_metadata_recovery.py
@@ -181,14 +181,12 @@
if not isinstance(api_key, str) or api_key not in recovered:
return row
meta: Final = recovered[api_key]
- return MappingProxyType(
- {
- **row,
- alias_field: meta.get("key_alias") or row.get(alias_field),
- team_id_field: meta.get("team_id") or row.get(team_id_field),
- user_email_field: row.get(user_email_field) or meta.get("user_email"),
- }
- )
+ return { # mutable-ok: polars.DataFrame row construction dispatches on dict, not Mapping
+ **row,
+ alias_field: meta.get("key_alias") or row.get(alias_field),
+ team_id_field: meta.get("team_id") or row.get(team_id_field),
+ user_email_field: row.get(user_email_field) or meta.get("user_email"),
+ }
async def fill_missing_api_key_aliases(
@@ -211,9 +209,7 @@
key
for row in rows
for key in (row.get(api_key_field),)
- if isinstance(key, str)
- and key
- and (row.get(alias_field) in (None, "") or row.get(user_email_field) in (None, ""))
+ if isinstance(key, str) and key and row.get(alias_field) in (None, "")
)
if not missing_keys:
return tuple(rows)You can send follow-ups to the cloud agent here.
Team and service keys often have no user_email after a successful token join. Treating empty email as a miss hashed every verification token on routine CloudZero and Focus exports. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
…and Focus exports
…//github.com/BerriAI/litellm into litellm_fix-batch-spend-key-double-hash-bcae
|
bugbot run |
|
Bugbot on tip b93cf2e found no new bugs in spend recovery or export attribution. |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit b93cf2e. Configure here.
…s all write paths One aliased key owned by a user with an email drives chat, queued chat, messages, responses, embeddings, the Gemini passthrough, a batch file upload, and a batch create against a live proxy. Each row must carry api_key equal to the key's LiteLLM_VerificationToken.token and the alias in metadata, and /spend/logs?api_key= and /user/daily/activity must report the key with its alias and email. Health-check rows must keep the literal service-account key, and the batch cost row for a completed marker batch must join the key that created it. A re-hashed api_key (the v1.99.0 regression fixed by BerriAI#39568 and BerriAI#39572) now fails the Buildkite e2e stage naming the write path Resolves MAT-180
…00.1) (#544) This PR contains the following updates: | Package | Update | Change | |---|---|---| | [ghcr.io/berriai/litellm](https://images.chainguard.dev/directory/image/wolfi-base/overview) ([source](https://github.com/BerriAI/litellm)) | patch | `v1.100.0` → `v1.100.1` | --- >⚠️ **Warning** > > Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/141) for more information. --- ### Release Notes <details> <summary>BerriAI/litellm (ghcr.io/berriai/litellm)</summary> ### [`v1.100.1`](https://github.com/BerriAI/litellm/releases/tag/v1.100.1) [Compare Source](BerriAI/litellm@v1.100.0...v1.100.1) ##### Verify Docker Image Signature All LiteLLM Docker images are signed with [cosign](https://docs.sigstore.dev/cosign/overview/). Every release is signed with the same key introduced in [commit `0112e53`](BerriAI/litellm@0112e53). **Verify using the pinned commit hash (recommended):** A commit hash is cryptographically immutable, so this is the strongest way to ensure you are using the original signing key: ```bash cosign verify \ --key https://raw.githubusercontent.com/BerriAI/litellm/0112e53046018d726492c814b3644b7d376029d0/cosign.pub \ ghcr.io/berriai/litellm:v1.100.1 ``` **Verify using the release tag (convenience):** Tags are protected in this repository and resolve to the same key. This option is easier to read but relies on tag protection rules: ```bash cosign verify \ --key https://raw.githubusercontent.com/BerriAI/litellm/v1.100.1/cosign.pub \ ghcr.io/berriai/litellm:v1.100.1 ``` Expected output: ``` The following checks were performed on each of these signatures: - The cosign claims were validated - The signatures were verified against the specified public key ``` *** ##### What's Changed - chore(release): backport [#​39568](BerriAI/litellm#39568) and [#​39572](BerriAI/litellm#39572) to stable/1.100.x and cut 1.100.1 by [@​devin-ai-integration](https://github.com/devin-ai-integration)\[bot] in [#​40176](BerriAI/litellm#40176) - fix(router): backport [#​39491](BerriAI/litellm#39491) to stable/1.100.x so retry breadcrumbs stop retaining every earlier request by [@​devin-ai-integration](https://github.com/devin-ai-integration)\[bot] in [#​40455](BerriAI/litellm#40455) - revert: drop the spend attribution backports from stable/1.100.x by [@​devin-ai-integration](https://github.com/devin-ai-integration)\[bot] in [#​40495](BerriAI/litellm#40495) **Full Changelog**: <BerriAI/litellm@v1.100.0...v1.100.1> </details> --- ### Configuration 📅 **Schedule**: (in timezone Europe/London) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC43NS4wIiwidXBkYXRlZEluVmVyIjoiNDQuNzkuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsicmVub3ZhdGUvY29udGFpbmVyIiwidHlwZS9wYXRjaCJdfQ==--> Reviewed-on: https://git.hayden.moe/hayden/phoebe/pulls/544
…nance gate (BerriAI#39568) Backport of BerriAI#39568 to stable/1.100.x. Cherry-picked from merge commit 04a198e (litellm_internal_staging) with -m 1.
…99 provenance gate (BerriAI#39568)" This reverts commit 803e0f7.

TLDR
Problem this solves:
api_keykey-hash-...with nullapi_key_aliasanduser_emailHow it solves it:
user_api_key_hashon batch cost, legacy queue, and callback-log replay writersuser_emailonto every key breakdownUser Flow
Before: a proxy admin looks at Usage Key Activity after a managed batch finishes and cannot tell which virtual key spent the money
batch-worker) owned byalice@example.comkey-hash-<64 hex chars>with emptyapi_key_aliasand emptyuser_email, so cost attribution looks unassignedAfter: the same batch spend stays attached to the creating key's alias and user
batch-workerbatch-workerwithalice@example.comRelevant issues
Customer report: since 1.99, hashed API keys show with no
user_email/api_key_alias, dirtying cost data and Usage Key ActivityRegression introduced by #30736 (security fix for #30670), shipped in v1.99.0. Already-hashed writer paths that omitted
user_api_key_hashgot hashed a second timeLinear ticket
Resolves LIT-6852
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Screenshots / Proof of Fix
Verdict: PASS
Topology: one proxy per leg, booted from that leg's commit with
--num_workers 2(two worker processes, each running the managed-batch cost poller withproxy_batch_polling_interval: 15), sharing one local Postgres databaselit6852_qa, enterprise license loaded for managed batches, real OpenAI calls togpt-5.4-nano. Before ran at the merge baseab0478f068(port 44948, exports on a second base proxy on 24381). After ran at the tipb93cf2e20a(port 39869) with every base proxy stopped first, so only tip pollers bill batches.$LITELLM_MASTER_KEY,$BATCH_WORKER_KEYand$QUEUE_WORKER_KEYstand in for the real keys. The v1.99 double hash of thebatch-workertokend4cdba72...isb5e1826d..., and of thequeue-workertoken184af3b2...is085604bc...Shared setup (run once against the base proxy, both legs reuse the keys)
Before (ab0478f)
The managed batch is created with the
batch-workerkey, the legacy queue call with thequeue-workerkey, then Usage Key Activity and the CloudZero and Vantage exports are read back. Both spends land under the double hash with no alias and no emailAfter (b93cf2e)
Key Activity on the tip proxy right before the After leg, for the deltas below
Same flow against the tip proxy. The batch cost now lands on the real
batch-workerhashd4cdba72...(11 requests, spend up by the batch cost from0.0000038to0.000008325) and the queue call on the realqueue-workerhash184af3b2.... The historical double-hash rows written before the fix (b5e1826d...,085604bc...) keep their spend but now resolve to the alias and the owner email, in Key Activity and in both exportsDeleted-key recovery path: delete the
batch-workerkey and read the same surfaces again. Key Activity still labels the double-hash row through the deleted-key lookupObservations from the run, each with what this PR does about it
user_emailbefore; only the alias was null. Not caused by this PRAdmin UI walkthrough against the tip proxy (
npm run devinui/litellm-dashboard, proxy on 39869)batch-workerorqueue-workerwith the owner email under it, and no row readskey-hash-...Type
🐛 Bug Fix
Caveats (if any)
Low
key_aliasstill render askey-hash-<token>in Usage. Both by designuser_emailfor every scope that already returns the key (user, team, tag, org). The same audiences already see member emails in team and user viewsFinal Attestation