fix(spend): attribute CLI session spend to the per-user cli-session alias instead of the hashed session token - #40541
mateo-berri wants to merge 4 commits into
Conversation
…lias instead of the hashed session token A CLI session token is a fresh random secret on every login, so since v1.99 each login's spend rows carried a different sha256 hash as api_key and the usage APIs could resolve neither key_alias nor user_email for them. Spend rows and logging callbacks now attribute a session request to its stable alias, cli-session-<user_id>, and the usage endpoints derive that alias and owner from the key itself instead of scanning for a matching digest
…n usage metadata A cli-session key carries no team of its own in the DB, so the usage breakdown showed team_id None for it and the export grouped it as Unassigned. The login attaches the user's first team to the session, so the recovery mirrors that rule for cli-session keys only.
The CLI login attaches a team on its own only when the user has exactly one; a user in several teams picks one per login, so usage metadata for the alias would otherwise name a team the login may not have used.
Greptile SummaryThis PR attributes CLI session spend to the stable per-user session alias while retaining hashed identities for ordinary virtual keys
Confidence Score: 5/5The PR appears safe to merge, with no outstanding or newly introduced actionable findings The current implementation consistently derives spend identity from trusted authentication state, preserves credential redaction, and keeps non-session key behavior unchanged. The changes-since-previous-review diff is empty, and no previous Greptile findings were provided
|
| Filename | Overview |
|---|---|
| litellm/proxy/litellm_pre_call_utils.py | Centralizes the logged identity selection so authenticated CLI sessions use their stable alias |
| litellm/proxy/spend_tracking/spend_tracking_utils.py | Preserves trusted CLI aliases during redaction while hashing untrusted or mismatched values |
| litellm/proxy/spend_tracking/key_metadata_recovery.py | Recovers CLI session ownership directly from the alias and fills user details from persisted user data |
| litellm/proxy/management_endpoints/common_daily_activity.py | Integrates CLI alias recovery into daily activity metadata resolution |
| litellm/proxy/hooks/proxy_track_cost_callback.py | Uses the same logged CLI identity for failure records and recovered-cost updates |
| tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py | Covers alias attribution and rejection of untrusted CLI-prefixed values |
Reviews (3): Last reviewed commit: "test(pass_through): mark the mocked auth..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
The logged key follows the alias only for a session token; a bare MagicMock reads as one, so the test names the field it relies on.
|
bugbot run |
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 1d0dc56. Configure here.
TLDR
Problem this solves:
litellm-proxy loginspends under a fresh sha256 hashapi_keylost the user behind each CLI sessionHow it solves it:
cli-session-<user_id>User Flow
Before: a developer who signs in with
litellm-proxy loginshows up in usage as a new 64-hex key every time they log in, with no alias or email next to itlitellm-proxy login, finish the Google sign-in in the browser, and the CLI stores a session token in$HOME/.litellm/token.json7cc85d06...and7b782cfc..., three requests each; on the customer's version those rows carry nokey_alias,user_email, orteam_idapi_keyto a user finds nothing for eitherAfter: the same developer's spend rolls up under one stable key named after them, with alias, email, and team filled in
litellm-proxy login, finish the Google sign-in in the browser, and the CLI stores a session token in$HOME/.litellm/token.jsoncli-session-<user_id>, six requests, withkey_alias,user_email, andteam_idfilled incli-session-<user_id>, and the BI export joins it to the user by namecli-session-<user_id>rowWhat
api_keymeans for a CLI session rowLiteLLM_SpendLogs.api_keyis the identity spend is attributed to and grouped by, and for a virtual key that is the sha256 of the secret because the secret is the only stable handle a key has. A CLI session has a different stable handle: the session key is minted fresh per login, but the aliascli-session-<user_id>is the same for every login of that user, so that alias is what a session row should carry. Everything that is not a session key keeps the sha256 gate exactly as before, andUserAPIKeyAuth.api_keystill holds the session token, so per-session rate limits, caching, and budget checks do not changeAlternatives I rejected:
NOT LIKE 'litellm-%'style prefix filters, and it hashes a value that was never a secretConsumers of
api_keyon this path and what changed for each:LiteLLM_Daily{User,Team,Tag}Spendaggregation: keyed by the alias now, so a user gets one row per day, model, and endpoint instead of one per login/user/daily/activityapi_keysmetadata (Usage page): acli-session-key resolveskey_aliasanduser_idfrom the key itself,user_emailfrom the user row, andteam_idfrom the user's only team; this no longer depends on a spend-log row being inside the read windowfill_missing_api_key_aliases): session keys resolve from the key before the reverse-hash lookup runsuser_api_key_hash: carry the alias for session requests, the hash for everything elseLiteLLM_VerificationTokenrow), same as todayNOT LIKE 'litellm-%'filters: unaffected, the alias starts withcli-session-The redaction gate accepts a
cli-session-value only when it equals the trusted alias from auth, so a client cannot smuggle an arbitrarycli-session-string into a spend rowBackfill (customer-run SQL, not a migration)
This is deliberately not a Prisma migration: migrations run at boot before the proxy serves traffic, and rewriting
LiteLLM_SpendLogsthere is downtime. Run it by hand withpsql, off-peak, with:sinceset to a date before the first CLI login you care about. PART 1 is read only and prints what would move. PART 2 is one transaction that rewritesLiteLLM_SpendLogs.api_keyin place and folds the per-login rows of the three daily tables into the alias row for the same day, model, provider, endpoint, and owner, then prints per-key totals before and after so you can compare them before typingCOMMIT. Rows written on v1.98 (rawcli-session-<random>token as the key) are mapped too, through the alias stored in the row's metadata. Session keys whose spend logs are already past retention cannot be mapped and stay as they arecli_session_backfill.sql
Relevant issues
None on GitHub; this came in through a customer support thread
Linear 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)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Shared setup for both legs. One proxy instance with two workers on a fresh Postgres database, Google SSO wired up for the CLI, and the config below. Two real
litellm-proxy loginsessions for the same SSO user (usermateo-cli-qa, single teamcli-qa-team), so login 1 and login 2 hold two different session tokens. Nothing on this path is per-process (the logged key is computed per request from the auth result and the usage routes read the database), so both workers behave the same; the before leg ran at the merge base with the four spend tables empty, and the after leg ran at the tip on top of the before leg's rowsDATABASE_URL,LITELLM_MASTER_KEY, and the Google SSO client come from the environmentBefore (db7ca65)
POST /chat/completions
KEYset to the login 1 token and then the login 2 token (thekeyfield of$HOME/.litellm/token.jsonafterlitellm-proxy login):POST /v1/messages
KEYset to the login 1 token and then the login 2 token (thekeyfield of$HOME/.litellm/token.jsonafterlitellm-proxy login):POST /v1/responses
KEYset to the login 1 token and then the login 2 token (thekeyfield of$HOME/.litellm/token.jsonafterlitellm-proxy login):Usage routes
(daily spend flushed after 3x3s):/user/daily/activityprinted as one line per key frombreakdown.api_keys):Backfill
litellm-proxy loginAfter (660203a)
The only commit after 660203a is 1d0dc56, a test-only change (a mocked auth object in the pass-through tests gains
is_session_token = False), so it cannot change what this run observedPOST /chat/completions
KEYset to the login 1 token and then the login 2 token (thekeyfield of$HOME/.litellm/token.jsonafterlitellm-proxy login):POST /v1/messages
KEYset to the login 1 token and then the login 2 token (thekeyfield of$HOME/.litellm/token.jsonafterlitellm-proxy login):POST /v1/responses
KEYset to the login 1 token and then the login 2 token (thekeyfield of$HOME/.litellm/token.jsonafterlitellm-proxy login):Usage routes
(daily spend flushed after 6x3s):/user/daily/activityprinted as one line per key frombreakdown.api_keys):Backfill
COMMIT:Closing observations:
cli-session-mateo-cli-qaType
🐛 Bug Fix
Caveats (if any)
Severe
cli-session-<user_id>LiteLLM_SpendLogs.api_keyin place and folds daily rowsMedium
litellm-proxy logintokens, not a coding tooluser_api_key_hashin logging callbacks carries the alias for session requeststeam_idin usage metadata for the aliasLow
MIN(model_group)andMIN(request_id); both are informationallocal_testing_part1andllm_translation_testingare red on four mocked no-choices tests that fail the same way on staging pipeline 89447; test: give mocked chat completion clients a real payload so the no-choices guard does not trip #40517 fixes themFinal Attestation
The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR
660203a passes /live-pr-risk (1d0dc56 on top is test-only)