Skip to content

fix(spend): attribute CLI session spend to the per-user cli-session alias instead of the hashed session token - #40541

Open
mateo-berri wants to merge 4 commits into
mainfrom
litellm_lit6852_cli_session_spend_key
Open

mateo-berri wants to merge 4 commits into
mainfrom
litellm_lit6852_cli_session_spend_key

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Since v1.99 every litellm-proxy login spends under a fresh sha256 hash
  • Usage APIs and the Usage page show one key per login, no alias or email
  • The customer's BI joins on api_key lost the user behind each CLI session

How it solves it:

  • Session spend now logs under the stable per-user alias cli-session-<user_id>
  • Every unified endpoint (chat completions, messages, responses) writes that same identity
  • Usage metadata resolves alias, email, and team straight from the key
  • Customer-run SQL backfill folds the old per-login rows into the alias

User Flow

Before: a developer who signs in with litellm-proxy login shows up in usage as a new 64-hex key every time they log in, with no alias or email next to it

  1. They run litellm-proxy login, finish the Google sign-in in the browser, and the CLI stores a session token in $HOME/.litellm/token.json
  2. They send POST https://litellm-domain/v1/chat/completions, POST https://litellm-domain/v1/messages, and POST https://litellm-domain/v1/responses with that token and get normal 200s back
  3. They log in again the next day (new token) and send the same three requests
  4. The admin opens GET https://litellm-domain/user/daily/activity?start_date=...&end_date=... and sees two keys for that one person, 7cc85d06... and 7b782cfc..., three requests each; on the customer's version those rows carry no key_alias, user_email, or team_id
  5. GET https://litellm-domain/spend/logs?start_date=...&end_date=... lists spend under the two hashes, and the BI export that joins api_key to a user finds nothing for either

After: the same developer's spend rolls up under one stable key named after them, with alias, email, and team filled in

  1. They run litellm-proxy login, finish the Google sign-in in the browser, and the CLI stores a session token in $HOME/.litellm/token.json
  2. They send POST https://litellm-domain/v1/chat/completions, POST https://litellm-domain/v1/messages, and POST https://litellm-domain/v1/responses with that token and get normal 200s back
  3. They log in again the next day (new token) and send the same three requests
  4. The admin opens GET https://litellm-domain/user/daily/activity?start_date=...&end_date=... and sees one key for that person, cli-session-<user_id>, six requests, with key_alias, user_email, and team_id filled in
  5. GET https://litellm-domain/spend/logs?start_date=...&end_date=... lists the spend under cli-session-<user_id>, and the BI export joins it to the user by name
  6. For the rows written before the upgrade, the admin runs the backfill SQL below once (dry run first), and the older per-login hashes fold into the same cli-session-<user_id> row

What api_key means for a CLI session row

LiteLLM_SpendLogs.api_key is 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 alias cli-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, and UserAPIKeyAuth.api_key still holds the session token, so per-session rate limits, caching, and budget checks do not change

Alternatives I rejected:

  • sha256 of the session token (what v1.99+ does today): one key per login, opaque, and on the customer's version nothing can map it back to a user because there is no virtual-key row to reverse it against
  • the raw session token (what v1.98 did): also one key per login, and it puts a live bearer credential in a spend row
  • sha256 of the alias: stable per user, but opaque again, it breaks the customer's NOT LIKE 'litellm-%' style prefix filters, and it hashes a value that was never a secret

Consumers of api_key on this path and what changed for each:

  • LiteLLM_Daily{User,Team,Tag}Spend aggregation: keyed by the alias now, so a user gets one row per day, model, and endpoint instead of one per login
  • /user/daily/activity api_keys metadata (Usage page): a cli-session- key resolves key_alias and user_id from the key itself, user_email from the user row, and team_id from the user's only team; this no longer depends on a spend-log row being inside the read window
    • Only team, not first team: the CLI login asks a user in several teams to pick one per login, so the first team in the user row can be wrong for them and the alias claims no team instead; spend rows and team views keep the team each login attached
  • CloudZero and Focus exports (fill_missing_api_key_aliases): session keys resolve from the key before the reverse-hash lookup runs
  • fix(spend-tracking): recover key alias for session tokens from spend logs #40275's sha256-gated recovery: untouched, it still runs for hashed keys and skips the alias since it is not a digest
  • Prometheus and logging callbacks that read user_api_key_hash: carry the alias for session requests, the hash for everything else
  • key spend updates: still a no-op for session keys (there is no LiteLLM_VerificationToken row), same as today
  • the customer's BI NOT LIKE 'litellm-%' filters: unaffected, the alias starts with cli-session-

The redaction gate accepts a cli-session- value only when it equals the trusted alias from auth, so a client cannot smuggle an arbitrary cli-session- string into a spend row

Backfill (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_SpendLogs there is downtime. Run it by hand with psql, off-peak, with :since set 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 rewrites LiteLLM_SpendLogs.api_key in 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 typing COMMIT. Rows written on v1.98 (raw cli-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 are

psql "$DATABASE_URL" -v since=2026-01-01 -f cli_session_backfill.sql
cli_session_backfill.sql
-- CLI session spend backfill (run by hand with psql, never as a proxy boot migration)
-- What it does: rows written by `litellm-proxy login` sessions carry a per-login api_key
-- (a 64-hex sha256 since v1.99, a raw cli-session-<random> token on v1.98). This rewrites
-- them to the per-user alias cli-session-<user_id> that fixed proxies write, so one user
-- rolls up to one key: LiteLLM_SpendLogs.api_key is rewritten in place, and the per-session
-- rows of LiteLLM_DailyUserSpend, LiteLLM_DailyTeamSpend, and LiteLLM_DailyTagSpend are
-- summed into the alias row for the same day, model, provider, endpoint, and owner.
-- How to run: set :since to a date before the first CLI login you care about, run PART 1
-- (read only) and read the counts, then run PART 2. PART 2 is one transaction; it prints
-- the same totals before and after so you can compare them before typing COMMIT.
-- Session keys whose SpendLogs rows are already past your retention cannot be mapped to a
-- user and are left as they are.

\set since '2026-01-01'

-- PART 1: dry run (read only) -------------------------------------------------------------
BEGIN;
CREATE TEMP TABLE cli_session_key_map AS
SELECT DISTINCT api_key AS session_key, metadata->>'user_api_key_alias' AS alias
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= :'since'::timestamp
  AND metadata->>'user_api_key_alias' LIKE 'cli-session-%'
  AND api_key <> metadata->>'user_api_key_alias'
  AND (api_key ~ '^[0-9a-f]{64}$' OR api_key LIKE 'cli-session-%')
UNION
SELECT DISTINCT api_key, 'cli-session-' || user_id
FROM "LiteLLM_DailyUserSpend"
WHERE api_key LIKE 'cli-session-%' AND user_id <> '' AND api_key <> 'cli-session-' || user_id;

SELECT alias, count(*) AS session_keys FROM cli_session_key_map GROUP BY alias ORDER BY alias;
SELECT count(*) AS spend_logs_to_rewrite FROM "LiteLLM_SpendLogs" s JOIN cli_session_key_map m ON s.api_key = m.session_key;
SELECT 'LiteLLM_DailyUserSpend' AS tbl, count(*) AS rows_to_fold, sum(spend) AS spend FROM "LiteLLM_DailyUserSpend" t JOIN cli_session_key_map m ON t.api_key = m.session_key
UNION ALL SELECT 'LiteLLM_DailyTeamSpend', count(*), sum(spend) FROM "LiteLLM_DailyTeamSpend" t JOIN cli_session_key_map m ON t.api_key = m.session_key
UNION ALL SELECT 'LiteLLM_DailyTagSpend', count(*), sum(spend) FROM "LiteLLM_DailyTagSpend" t JOIN cli_session_key_map m ON t.api_key = m.session_key;
ROLLBACK;

-- PART 2: apply ---------------------------------------------------------------------------
BEGIN;
CREATE TEMP TABLE cli_session_key_map AS
SELECT DISTINCT api_key AS session_key, metadata->>'user_api_key_alias' AS alias
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= :'since'::timestamp
  AND metadata->>'user_api_key_alias' LIKE 'cli-session-%'
  AND api_key <> metadata->>'user_api_key_alias'
  AND (api_key ~ '^[0-9a-f]{64}$' OR api_key LIKE 'cli-session-%')
UNION
SELECT DISTINCT api_key, 'cli-session-' || user_id
FROM "LiteLLM_DailyUserSpend"
WHERE api_key LIKE 'cli-session-%' AND user_id <> '' AND api_key <> 'cli-session-' || user_id;

CREATE TEMP TABLE totals_before AS
SELECT 'LiteLLM_DailyUserSpend' AS tbl, coalesce(m.alias, t.api_key) AS key, sum(t.spend) AS spend, sum(t.api_requests) AS api_requests FROM "LiteLLM_DailyUserSpend" t LEFT JOIN cli_session_key_map m ON t.api_key = m.session_key GROUP BY 1, 2
UNION ALL SELECT 'LiteLLM_DailyTeamSpend', coalesce(m.alias, t.api_key), sum(t.spend), sum(t.api_requests) FROM "LiteLLM_DailyTeamSpend" t LEFT JOIN cli_session_key_map m ON t.api_key = m.session_key GROUP BY 1, 2
UNION ALL SELECT 'LiteLLM_DailyTagSpend', coalesce(m.alias, t.api_key), sum(t.spend), sum(t.api_requests) FROM "LiteLLM_DailyTagSpend" t LEFT JOIN cli_session_key_map m ON t.api_key = m.session_key GROUP BY 1, 2;

UPDATE "LiteLLM_SpendLogs" s SET api_key = m.alias
FROM cli_session_key_map m
WHERE s."startTime" >= :'since'::timestamp AND s.api_key = m.session_key;

-- LiteLLM_DailyUserSpend: fold each per-session row into the row of the same day, model, and owner under the alias
WITH folded AS (
  SELECT t.user_id, t.date, m.alias AS api_key, t.model, t.custom_llm_provider, t.mcp_namespaced_tool_name, t.endpoint, MIN(t.model_group) AS model_group, SUM(t.prompt_tokens) AS prompt_tokens, SUM(t.completion_tokens) AS completion_tokens, SUM(t.cache_read_input_tokens) AS cache_read_input_tokens, SUM(t.cache_creation_input_tokens) AS cache_creation_input_tokens, SUM(t.compression_saved_tokens) AS compression_saved_tokens, SUM(t.compression_savings_spend) AS compression_savings_spend, SUM(t.prompt_caching_savings_spend) AS prompt_caching_savings_spend, SUM(t.gateway_injected_caching_savings_spend) AS gateway_injected_caching_savings_spend, SUM(t.autorouter_savings_spend) AS autorouter_savings_spend, SUM(t.spend) AS spend, SUM(t.api_requests) AS api_requests, SUM(t.successful_requests) AS successful_requests, SUM(t.failed_requests) AS failed_requests
  FROM "LiteLLM_DailyUserSpend" t JOIN cli_session_key_map m ON t.api_key = m.session_key
  GROUP BY t.user_id, t.date, t.model, t.custom_llm_provider, t.mcp_namespaced_tool_name, t.endpoint, m.alias
), updated AS (
  UPDATE "LiteLLM_DailyUserSpend" t SET prompt_tokens = t.prompt_tokens + f.prompt_tokens, completion_tokens = t.completion_tokens + f.completion_tokens, cache_read_input_tokens = t.cache_read_input_tokens + f.cache_read_input_tokens, cache_creation_input_tokens = t.cache_creation_input_tokens + f.cache_creation_input_tokens, compression_saved_tokens = t.compression_saved_tokens + f.compression_saved_tokens, compression_savings_spend = t.compression_savings_spend + f.compression_savings_spend, prompt_caching_savings_spend = t.prompt_caching_savings_spend + f.prompt_caching_savings_spend, gateway_injected_caching_savings_spend = t.gateway_injected_caching_savings_spend + f.gateway_injected_caching_savings_spend, autorouter_savings_spend = t.autorouter_savings_spend + f.autorouter_savings_spend, spend = t.spend + f.spend, api_requests = t.api_requests + f.api_requests, successful_requests = t.successful_requests + f.successful_requests, failed_requests = t.failed_requests + f.failed_requests, updated_at = now()
  FROM folded f
  WHERE t.user_id IS NOT DISTINCT FROM f.user_id AND t.date IS NOT DISTINCT FROM f.date AND t.api_key IS NOT DISTINCT FROM f.api_key AND t.model IS NOT DISTINCT FROM f.model AND t.custom_llm_provider IS NOT DISTINCT FROM f.custom_llm_provider AND t.mcp_namespaced_tool_name IS NOT DISTINCT FROM f.mcp_namespaced_tool_name AND t.endpoint IS NOT DISTINCT FROM f.endpoint
  RETURNING f.user_id, f.date, f.api_key, f.model, f.custom_llm_provider, f.mcp_namespaced_tool_name, f.endpoint
)
INSERT INTO "LiteLLM_DailyUserSpend" (id, user_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint, model_group, prompt_tokens, completion_tokens, cache_read_input_tokens, cache_creation_input_tokens, compression_saved_tokens, compression_savings_spend, prompt_caching_savings_spend, gateway_injected_caching_savings_spend, autorouter_savings_spend, spend, api_requests, successful_requests, failed_requests, created_at, updated_at)
SELECT gen_random_uuid()::text, f.user_id, f.date, f.api_key, f.model, f.custom_llm_provider, f.mcp_namespaced_tool_name, f.endpoint, f.model_group, f.prompt_tokens, f.completion_tokens, f.cache_read_input_tokens, f.cache_creation_input_tokens, f.compression_saved_tokens, f.compression_savings_spend, f.prompt_caching_savings_spend, f.gateway_injected_caching_savings_spend, f.autorouter_savings_spend, f.spend, f.api_requests, f.successful_requests, f.failed_requests, now(), now()
FROM folded f
WHERE NOT EXISTS (SELECT 1 FROM updated u WHERE u.user_id IS NOT DISTINCT FROM f.user_id AND u.date IS NOT DISTINCT FROM f.date AND u.api_key IS NOT DISTINCT FROM f.api_key AND u.model IS NOT DISTINCT FROM f.model AND u.custom_llm_provider IS NOT DISTINCT FROM f.custom_llm_provider AND u.mcp_namespaced_tool_name IS NOT DISTINCT FROM f.mcp_namespaced_tool_name AND u.endpoint IS NOT DISTINCT FROM f.endpoint);

DELETE FROM "LiteLLM_DailyUserSpend" t USING cli_session_key_map m WHERE t.api_key = m.session_key;

-- LiteLLM_DailyTeamSpend: fold each per-session row into the row of the same day, model, and owner under the alias
WITH folded AS (
  SELECT t.team_id, t.date, m.alias AS api_key, t.model, t.custom_llm_provider, t.mcp_namespaced_tool_name, t.endpoint, MIN(t.model_group) AS model_group, SUM(t.prompt_tokens) AS prompt_tokens, SUM(t.completion_tokens) AS completion_tokens, SUM(t.cache_read_input_tokens) AS cache_read_input_tokens, SUM(t.cache_creation_input_tokens) AS cache_creation_input_tokens, SUM(t.compression_saved_tokens) AS compression_saved_tokens, SUM(t.compression_savings_spend) AS compression_savings_spend, SUM(t.prompt_caching_savings_spend) AS prompt_caching_savings_spend, SUM(t.gateway_injected_caching_savings_spend) AS gateway_injected_caching_savings_spend, SUM(t.autorouter_savings_spend) AS autorouter_savings_spend, SUM(t.spend) AS spend, SUM(t.api_requests) AS api_requests, SUM(t.successful_requests) AS successful_requests, SUM(t.failed_requests) AS failed_requests, SUM(t.ptu_flat_cost) AS ptu_flat_cost
  FROM "LiteLLM_DailyTeamSpend" t JOIN cli_session_key_map m ON t.api_key = m.session_key
  GROUP BY t.team_id, t.date, t.model, t.custom_llm_provider, t.mcp_namespaced_tool_name, t.endpoint, m.alias
), updated AS (
  UPDATE "LiteLLM_DailyTeamSpend" t SET prompt_tokens = t.prompt_tokens + f.prompt_tokens, completion_tokens = t.completion_tokens + f.completion_tokens, cache_read_input_tokens = t.cache_read_input_tokens + f.cache_read_input_tokens, cache_creation_input_tokens = t.cache_creation_input_tokens + f.cache_creation_input_tokens, compression_saved_tokens = t.compression_saved_tokens + f.compression_saved_tokens, compression_savings_spend = t.compression_savings_spend + f.compression_savings_spend, prompt_caching_savings_spend = t.prompt_caching_savings_spend + f.prompt_caching_savings_spend, gateway_injected_caching_savings_spend = t.gateway_injected_caching_savings_spend + f.gateway_injected_caching_savings_spend, autorouter_savings_spend = t.autorouter_savings_spend + f.autorouter_savings_spend, spend = t.spend + f.spend, api_requests = t.api_requests + f.api_requests, successful_requests = t.successful_requests + f.successful_requests, failed_requests = t.failed_requests + f.failed_requests, ptu_flat_cost = t.ptu_flat_cost + f.ptu_flat_cost, updated_at = now()
  FROM folded f
  WHERE t.team_id IS NOT DISTINCT FROM f.team_id AND t.date IS NOT DISTINCT FROM f.date AND t.api_key IS NOT DISTINCT FROM f.api_key AND t.model IS NOT DISTINCT FROM f.model AND t.custom_llm_provider IS NOT DISTINCT FROM f.custom_llm_provider AND t.mcp_namespaced_tool_name IS NOT DISTINCT FROM f.mcp_namespaced_tool_name AND t.endpoint IS NOT DISTINCT FROM f.endpoint
  RETURNING f.team_id, f.date, f.api_key, f.model, f.custom_llm_provider, f.mcp_namespaced_tool_name, f.endpoint
)
INSERT INTO "LiteLLM_DailyTeamSpend" (id, team_id, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint, model_group, prompt_tokens, completion_tokens, cache_read_input_tokens, cache_creation_input_tokens, compression_saved_tokens, compression_savings_spend, prompt_caching_savings_spend, gateway_injected_caching_savings_spend, autorouter_savings_spend, spend, api_requests, successful_requests, failed_requests, ptu_flat_cost, created_at, updated_at)
SELECT gen_random_uuid()::text, f.team_id, f.date, f.api_key, f.model, f.custom_llm_provider, f.mcp_namespaced_tool_name, f.endpoint, f.model_group, f.prompt_tokens, f.completion_tokens, f.cache_read_input_tokens, f.cache_creation_input_tokens, f.compression_saved_tokens, f.compression_savings_spend, f.prompt_caching_savings_spend, f.gateway_injected_caching_savings_spend, f.autorouter_savings_spend, f.spend, f.api_requests, f.successful_requests, f.failed_requests, f.ptu_flat_cost, now(), now()
FROM folded f
WHERE NOT EXISTS (SELECT 1 FROM updated u WHERE u.team_id IS NOT DISTINCT FROM f.team_id AND u.date IS NOT DISTINCT FROM f.date AND u.api_key IS NOT DISTINCT FROM f.api_key AND u.model IS NOT DISTINCT FROM f.model AND u.custom_llm_provider IS NOT DISTINCT FROM f.custom_llm_provider AND u.mcp_namespaced_tool_name IS NOT DISTINCT FROM f.mcp_namespaced_tool_name AND u.endpoint IS NOT DISTINCT FROM f.endpoint);

DELETE FROM "LiteLLM_DailyTeamSpend" t USING cli_session_key_map m WHERE t.api_key = m.session_key;

-- LiteLLM_DailyTagSpend: fold each per-session row into the row of the same day, model, and owner under the alias
WITH folded AS (
  SELECT t.tag, t.date, m.alias AS api_key, t.model, t.custom_llm_provider, t.mcp_namespaced_tool_name, t.endpoint, MIN(t.model_group) AS model_group, MIN(t.request_id) AS request_id, SUM(t.prompt_tokens) AS prompt_tokens, SUM(t.completion_tokens) AS completion_tokens, SUM(t.cache_read_input_tokens) AS cache_read_input_tokens, SUM(t.cache_creation_input_tokens) AS cache_creation_input_tokens, SUM(t.compression_saved_tokens) AS compression_saved_tokens, SUM(t.compression_savings_spend) AS compression_savings_spend, SUM(t.prompt_caching_savings_spend) AS prompt_caching_savings_spend, SUM(t.gateway_injected_caching_savings_spend) AS gateway_injected_caching_savings_spend, SUM(t.autorouter_savings_spend) AS autorouter_savings_spend, SUM(t.spend) AS spend, SUM(t.api_requests) AS api_requests, SUM(t.successful_requests) AS successful_requests, SUM(t.failed_requests) AS failed_requests
  FROM "LiteLLM_DailyTagSpend" t JOIN cli_session_key_map m ON t.api_key = m.session_key
  GROUP BY t.tag, t.date, t.model, t.custom_llm_provider, t.mcp_namespaced_tool_name, t.endpoint, m.alias
), updated AS (
  UPDATE "LiteLLM_DailyTagSpend" t SET prompt_tokens = t.prompt_tokens + f.prompt_tokens, completion_tokens = t.completion_tokens + f.completion_tokens, cache_read_input_tokens = t.cache_read_input_tokens + f.cache_read_input_tokens, cache_creation_input_tokens = t.cache_creation_input_tokens + f.cache_creation_input_tokens, compression_saved_tokens = t.compression_saved_tokens + f.compression_saved_tokens, compression_savings_spend = t.compression_savings_spend + f.compression_savings_spend, prompt_caching_savings_spend = t.prompt_caching_savings_spend + f.prompt_caching_savings_spend, gateway_injected_caching_savings_spend = t.gateway_injected_caching_savings_spend + f.gateway_injected_caching_savings_spend, autorouter_savings_spend = t.autorouter_savings_spend + f.autorouter_savings_spend, spend = t.spend + f.spend, api_requests = t.api_requests + f.api_requests, successful_requests = t.successful_requests + f.successful_requests, failed_requests = t.failed_requests + f.failed_requests, updated_at = now()
  FROM folded f
  WHERE t.tag IS NOT DISTINCT FROM f.tag AND t.date IS NOT DISTINCT FROM f.date AND t.api_key IS NOT DISTINCT FROM f.api_key AND t.model IS NOT DISTINCT FROM f.model AND t.custom_llm_provider IS NOT DISTINCT FROM f.custom_llm_provider AND t.mcp_namespaced_tool_name IS NOT DISTINCT FROM f.mcp_namespaced_tool_name AND t.endpoint IS NOT DISTINCT FROM f.endpoint
  RETURNING f.tag, f.date, f.api_key, f.model, f.custom_llm_provider, f.mcp_namespaced_tool_name, f.endpoint
)
INSERT INTO "LiteLLM_DailyTagSpend" (id, tag, date, api_key, model, custom_llm_provider, mcp_namespaced_tool_name, endpoint, model_group, request_id, prompt_tokens, completion_tokens, cache_read_input_tokens, cache_creation_input_tokens, compression_saved_tokens, compression_savings_spend, prompt_caching_savings_spend, gateway_injected_caching_savings_spend, autorouter_savings_spend, spend, api_requests, successful_requests, failed_requests, created_at, updated_at)
SELECT gen_random_uuid()::text, f.tag, f.date, f.api_key, f.model, f.custom_llm_provider, f.mcp_namespaced_tool_name, f.endpoint, f.model_group, f.request_id, f.prompt_tokens, f.completion_tokens, f.cache_read_input_tokens, f.cache_creation_input_tokens, f.compression_saved_tokens, f.compression_savings_spend, f.prompt_caching_savings_spend, f.gateway_injected_caching_savings_spend, f.autorouter_savings_spend, f.spend, f.api_requests, f.successful_requests, f.failed_requests, now(), now()
FROM folded f
WHERE NOT EXISTS (SELECT 1 FROM updated u WHERE u.tag IS NOT DISTINCT FROM f.tag AND u.date IS NOT DISTINCT FROM f.date AND u.api_key IS NOT DISTINCT FROM f.api_key AND u.model IS NOT DISTINCT FROM f.model AND u.custom_llm_provider IS NOT DISTINCT FROM f.custom_llm_provider AND u.mcp_namespaced_tool_name IS NOT DISTINCT FROM f.mcp_namespaced_tool_name AND u.endpoint IS NOT DISTINCT FROM f.endpoint);

DELETE FROM "LiteLLM_DailyTagSpend" t USING cli_session_key_map m WHERE t.api_key = m.session_key;

-- Verification: every row should read the same before and after (spend and request totals per key)
SELECT b.tbl, b.key, b.spend AS spend_before, a.spend AS spend_after, b.api_requests AS requests_before, a.api_requests AS requests_after
FROM totals_before b
LEFT JOIN (
  SELECT 'LiteLLM_DailyUserSpend' AS tbl, api_key AS key, sum(spend) AS spend, sum(api_requests) AS api_requests FROM "LiteLLM_DailyUserSpend" GROUP BY 1, 2
  UNION ALL SELECT 'LiteLLM_DailyTeamSpend', api_key, sum(spend), sum(api_requests) FROM "LiteLLM_DailyTeamSpend" GROUP BY 1, 2
  UNION ALL SELECT 'LiteLLM_DailyTagSpend', api_key, sum(spend), sum(api_requests) FROM "LiteLLM_DailyTagSpend" GROUP BY 1, 2
) a ON a.tbl = b.tbl AND a.key = b.key
WHERE b.key LIKE 'cli-session-%'
ORDER BY 1, 2;
SELECT count(*) AS spend_logs_still_on_a_session_key FROM "LiteLLM_SpendLogs" s JOIN cli_session_key_map m ON s.api_key = m.session_key WHERE s."startTime" >= :'since'::timestamp;

-- Type COMMIT; when the totals match, ROLLBACK; otherwise

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

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. 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
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to 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 login sessions for the same SSO user (user mateo-cli-qa, single team cli-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 rows

python litellm/proxy/proxy_cli.py --config config.yaml --port 45780 --host 127.0.0.1 --detailed_debug --use_v2_migration_resolver --num_workers 2
model_list:
  - model_name: gpt-5.4-nano
    litellm_params:
      model: openai/gpt-5.4-nano
      api_key: os.environ/OPENAI_API_KEY
  - model_name: claude-haiku-4-5
    litellm_params:
      model: anthropic/claude-haiku-4-5
      api_key: os.environ/ANTHROPIC_API_KEY
general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY
  store_model_in_db: false

litellm_settings:
  drop_params: true
  telemetry: false

DATABASE_URL, LITELLM_MASTER_KEY, and the Google SSO client come from the environment

Before (db7ca65)

POST /chat/completions

  1. With KEY set to the login 1 token and then the login 2 token (the key field of $HOME/.litellm/token.json after litellm-proxy login):
curl -s http://127.0.0.1:45780/chat/completions -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model":"gpt-5.4-nano","messages":[{"role":"user","content":"Say BEFORE in one word"}],"max_tokens":16}'
  1. Output (trimmed to id, model, content, usage):
login 1: {"id": "chatcmpl-EMQPObJzafhSYHxNHwD3PWgYid1Tq", "model": "gpt-5.4-nano", "content": "PREVIOUSLY", "usage": {"prompt_tokens": 11, "completion_tokens": 6}}
login 2: {"id": "chatcmpl-EMQPQm292OrGVGk8W5vekt6od9B4P", "model": "gpt-5.4-nano", "content": "**Before**", "usage": {"prompt_tokens": 11, "completion_tokens": 6}}

POST /v1/messages

  1. With KEY set to the login 1 token and then the login 2 token (the key field of $HOME/.litellm/token.json after litellm-proxy login):
curl -s http://127.0.0.1:45780/v1/messages -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -H 'anthropic-version: 2023-06-01' -d '{"model":"claude-haiku-4-5","max_tokens":16,"messages":[{"role":"user","content":"Say BEFORE in one word"}]}'
  1. Output (trimmed to id, model, content, usage):
login 1: {"id": "msg_011Ceu71Vgi6ie3HsGKEnGHJ", "model": "claude-haiku-4-5", "content": "Before.", "usage": {"input_tokens": 13, "output_tokens": 5}}
login 2: {"id": "msg_011Ceu71fWoPVDMPHoJ1MPg4", "model": "claude-haiku-4-5", "content": "Before.", "usage": {"input_tokens": 13, "output_tokens": 5}}

POST /v1/responses

  1. With KEY set to the login 1 token and then the login 2 token (the key field of $HOME/.litellm/token.json after litellm-proxy login):
curl -s http://127.0.0.1:45780/v1/responses -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model":"gpt-5.4-nano","input":"Say BEFORE in one word","max_output_tokens":32}'
  1. Output (trimmed to id, model, content, usage):
login 1: {"id": "resp_5VtCoY_V7HcV_5J\u2026", "model": "gpt-5.4-nano", "output_text": ["\u201cBefore\u201d"], "usage": {"input_tokens": 11, "output_tokens": 7}}
login 2: {"id": "resp_hkzTULLnbtsrHQz\u2026", "model": "gpt-5.4-nano", "output_text": ["\u201cPrior\u201d"], "usage": {"input_tokens": 11, "output_tokens": 7}}

Usage routes

  1. Waited for the daily aggregates to flush, then read the three usage routes with the admin key and once more with the login 1 token (daily spend flushed after 3x3s):
curl -s "http://127.0.0.1:45780/spend/logs?start_date=2026-09-10&end_date=2026-09-11" -H "Authorization: Bearer $ADMIN"
curl -s "http://127.0.0.1:45780/spend/logs/ui?start_date=2026-09-10%2000:00:00&end_date=2026-09-11%2000:00:00&page_size=50" -H "Authorization: Bearer $ADMIN"
curl -s "http://127.0.0.1:45780/user/daily/activity?start_date=2026-09-10&end_date=2026-09-10" -H "Authorization: Bearer $ADMIN"
curl -s "http://127.0.0.1:45780/user/daily/activity?start_date=2026-09-10&end_date=2026-09-10" -H "Authorization: Bearer $KEY"
  1. Output (rows trimmed to the columns that matter; /user/daily/activity printed as one line per key from breakdown.api_keys):
## GET /spend/logs?start_date=2026-09-10&end_date=2026-09-11 (spend per api_key, admin key)
{"7b782cfc52df8ee6748bd867c5abf18ba44a8b97ac0e7a90550e9dedb6017b44": 5.865e-05, "7cc85d06641a8b9d4cc93d5e43f3aa4320d01749f3bc07e91c4e15d8030885d4": 5.865e-05, "startTime": "2026-09-10", "spend": 0.0001173, "users": {"mateo-cli-qa": 0.0001173}}
{"startTime": "2026-09-11", "spend": 0, "users": {}}
## GET /spend/logs/ui (what the Logs page lists, admin key)
{"startTime": "04:10:37", "call_type": "acompletion", "model": "openai/gpt-5.4-nano", "api_key": "7cc85d06641a8b9d4cc93d5e43f3aa4320d01749f3bc07e91c4e15d8030885d4", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 9.700000000000002e-06}
{"startTime": "04:10:38", "call_type": "anthropic_messages", "model": "anthropic/claude-haiku-4-5", "api_key": "7cc85d06641a8b9d4cc93d5e43f3aa4320d01749f3bc07e91c4e15d8030885d4", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 3.8e-05}
{"startTime": "04:10:39", "call_type": "aresponses", "model": "openai/gpt-5.4-nano", "api_key": "7cc85d06641a8b9d4cc93d5e43f3aa4320d01749f3bc07e91c4e15d8030885d4", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 1.095e-05}
{"startTime": "04:10:40", "call_type": "acompletion", "model": "openai/gpt-5.4-nano", "api_key": "7b782cfc52df8ee6748bd867c5abf18ba44a8b97ac0e7a90550e9dedb6017b44", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 9.700000000000002e-06}
{"startTime": "04:10:41", "call_type": "anthropic_messages", "model": "anthropic/claude-haiku-4-5", "api_key": "7b782cfc52df8ee6748bd867c5abf18ba44a8b97ac0e7a90550e9dedb6017b44", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 3.8e-05}
{"startTime": "04:10:41", "call_type": "aresponses", "model": "openai/gpt-5.4-nano", "api_key": "7b782cfc52df8ee6748bd867c5abf18ba44a8b97ac0e7a90550e9dedb6017b44", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 1.095e-05}
## GET /user/daily/activity?start_date=2026-09-10&end_date=2026-09-10 (admin key)
date 2026-09-10 spend 0.0001173 requests 6
  api_key 7b782cfc52df8ee6748bd867… | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 3 | spend 5.865e-05
  api_key 7cc85d06641a8b9d4cc93d5e… | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 3 | spend 5.865000000000001e-05
## same route with login 1 token (what the CLI user sees)
date 2026-09-10 spend 0.0001173 requests 6
  api_key 7b782cfc52df8ee6748bd867… | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 3 | spend 5.865e-05
  api_key 7cc85d06641a8b9d4cc93d5e… | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 3 | spend 5.865000000000001e-05

Backfill

  1. Not run here: the merge base keeps writing a new hash per login, so a backfill at this commit would be undone by the next litellm-proxy login

After (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 observed

POST /chat/completions

  1. With KEY set to the login 1 token and then the login 2 token (the key field of $HOME/.litellm/token.json after litellm-proxy login):
curl -s http://127.0.0.1:45780/chat/completions -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model":"gpt-5.4-nano","messages":[{"role":"user","content":"Say AFTER in one word"}],"max_tokens":16}'
  1. Output (trimmed to id, model, content, usage):
login 1: {"id": "chatcmpl-EMQPx8XWonCcWtsGTDIZry5Q7UYBq", "model": "gpt-5.4-nano", "content": "AFTER", "usage": {"prompt_tokens": 11, "completion_tokens": 5}}
login 2: {"id": "chatcmpl-EMQPzEn4JlYNxSUyrGr29ldrjVMx6", "model": "gpt-5.4-nano", "content": "\u201cAfter\u201d", "usage": {"prompt_tokens": 11, "completion_tokens": 6}}

POST /v1/messages

  1. With KEY set to the login 1 token and then the login 2 token (the key field of $HOME/.litellm/token.json after litellm-proxy login):
curl -s http://127.0.0.1:45780/v1/messages -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -H 'anthropic-version: 2023-06-01' -d '{"model":"claude-haiku-4-5","max_tokens":16,"messages":[{"role":"user","content":"Say AFTER in one word"}]}'
  1. Output (trimmed to id, model, content, usage):
login 1: {"id": "msg_011Ceu744wXACANEXmcGzyZv", "model": "claude-haiku-4-5", "content": "After.", "usage": {"input_tokens": 13, "output_tokens": 5}}
login 2: {"id": "msg_011Ceu74G1mTeDvA8coda9kE", "model": "claude-haiku-4-5", "content": "AFTER", "usage": {"input_tokens": 13, "output_tokens": 5}}

POST /v1/responses

  1. With KEY set to the login 1 token and then the login 2 token (the key field of $HOME/.litellm/token.json after litellm-proxy login):
curl -s http://127.0.0.1:45780/v1/responses -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{"model":"gpt-5.4-nano","input":"Say AFTER in one word","max_output_tokens":32}'
  1. Output (trimmed to id, model, content, usage):
login 1: {"id": "resp_1Fl5JeU03bchJ2h\u2026", "model": "gpt-5.4-nano", "output_text": ["AFTER"], "usage": {"input_tokens": 11, "output_tokens": 6}}
login 2: {"id": "resp_ZopKAgUQ6oYsyHL\u2026", "model": "gpt-5.4-nano", "output_text": ["AFTER"], "usage": {"input_tokens": 11, "output_tokens": 6}}

Usage routes

  1. Waited for the daily aggregates to flush, then read the three usage routes with the admin key and once more with the login 1 token (daily spend flushed after 6x3s):
curl -s "http://127.0.0.1:45780/spend/logs?start_date=2026-09-10&end_date=2026-09-11" -H "Authorization: Bearer $ADMIN"
curl -s "http://127.0.0.1:45780/spend/logs/ui?start_date=2026-09-10%2000:00:00&end_date=2026-09-11%2000:00:00&page_size=50" -H "Authorization: Bearer $ADMIN"
curl -s "http://127.0.0.1:45780/user/daily/activity?start_date=2026-09-10&end_date=2026-09-10" -H "Authorization: Bearer $ADMIN"
curl -s "http://127.0.0.1:45780/user/daily/activity?start_date=2026-09-10&end_date=2026-09-10" -H "Authorization: Bearer $KEY"
  1. Output (rows trimmed to the columns that matter; /user/daily/activity printed as one line per key from breakdown.api_keys):
## GET /spend/logs?start_date=2026-09-10&end_date=2026-09-11 (spend per api_key, admin key)
{"7cc85d06641a8b9d4cc93d5e43f3aa4320d01749f3bc07e91c4e15d8030885d4": 5.865e-05, "cli-session-mateo-cli-qa": 0.00011355000000000001, "7b782cfc52df8ee6748bd867c5abf18ba44a8b97ac0e7a90550e9dedb6017b44": 5.865e-05, "startTime": "2026-09-10", "spend": 0.00023085, "users": {"mateo-cli-qa": 0.00023085}}
{"startTime": "2026-09-11", "spend": 0, "users": {}}
## GET /spend/logs/ui (what the Logs page lists, admin key)
{"startTime": "04:10:37", "call_type": "acompletion", "model": "openai/gpt-5.4-nano", "api_key": "7cc85d06641a8b9d4cc93d5e43f3aa4320d01749f3bc07e91c4e15d8030885d4", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 9.700000000000002e-06}
{"startTime": "04:10:38", "call_type": "anthropic_messages", "model": "anthropic/claude-haiku-4-5", "api_key": "7cc85d06641a8b9d4cc93d5e43f3aa4320d01749f3bc07e91c4e15d8030885d4", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 3.8e-05}
{"startTime": "04:10:39", "call_type": "aresponses", "model": "openai/gpt-5.4-nano", "api_key": "7cc85d06641a8b9d4cc93d5e43f3aa4320d01749f3bc07e91c4e15d8030885d4", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 1.095e-05}
{"startTime": "04:10:40", "call_type": "acompletion", "model": "openai/gpt-5.4-nano", "api_key": "7b782cfc52df8ee6748bd867c5abf18ba44a8b97ac0e7a90550e9dedb6017b44", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 9.700000000000002e-06}
{"startTime": "04:10:41", "call_type": "anthropic_messages", "model": "anthropic/claude-haiku-4-5", "api_key": "7b782cfc52df8ee6748bd867c5abf18ba44a8b97ac0e7a90550e9dedb6017b44", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 3.8e-05}
{"startTime": "04:10:41", "call_type": "aresponses", "model": "openai/gpt-5.4-nano", "api_key": "7b782cfc52df8ee6748bd867c5abf18ba44a8b97ac0e7a90550e9dedb6017b44", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 1.095e-05}
{"startTime": "04:11:12", "call_type": "acompletion", "model": "openai/gpt-5.4-nano", "api_key": "cli-session-mateo-cli-qa", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 8.45e-06}
{"startTime": "04:11:13", "call_type": "anthropic_messages", "model": "anthropic/claude-haiku-4-5", "api_key": "cli-session-mateo-cli-qa", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 3.8e-05}
{"startTime": "04:11:14", "call_type": "aresponses", "model": "openai/gpt-5.4-nano", "api_key": "cli-session-mateo-cli-qa", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 9.700000000000002e-06}
{"startTime": "04:11:15", "call_type": "acompletion", "model": "openai/gpt-5.4-nano", "api_key": "cli-session-mateo-cli-qa", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 9.700000000000002e-06}
{"startTime": "04:11:16", "call_type": "anthropic_messages", "model": "anthropic/claude-haiku-4-5", "api_key": "cli-session-mateo-cli-qa", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 3.8e-05}
{"startTime": "04:11:17", "call_type": "aresponses", "model": "openai/gpt-5.4-nano", "api_key": "cli-session-mateo-cli-qa", "key_alias": "cli-session-mateo-cli-qa", "user": "mateo-cli-qa", "team_id": "cli-qa-team", "spend": 9.700000000000002e-06}
## GET /user/daily/activity?start_date=2026-09-10&end_date=2026-09-10 (admin key)
date 2026-09-10 spend 0.00023085 requests 12
  api_key 7b782cfc52df8ee6748bd867… | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 3 | spend 5.865e-05
  api_key 7cc85d06641a8b9d4cc93d5e… | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 3 | spend 5.865000000000001e-05
  api_key cli-session-mateo-cli-qa | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 6 | spend 0.00011355000000000001
## same route with login 1 token (what the CLI user sees)
date 2026-09-10 spend 0.00023085 requests 12
  api_key 7b782cfc52df8ee6748bd867… | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 3 | spend 5.865e-05
  api_key 7cc85d06641a8b9d4cc93d5e… | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 3 | spend 5.865000000000001e-05
  api_key cli-session-mateo-cli-qa | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 6 | spend 0.00011355000000000001

Backfill

  1. Dry run against the database left by the two legs above (six rows on two hashed session keys plus six on the alias):
$ psql "$DATABASE_URL" -v since=2026-01-01 -f backfill_part1.sql   # dry run, ends in ROLLBACK
BEGIN
SELECT 2
          alias           | session_keys 
--------------------------+--------------
 cli-session-mateo-cli-qa |            2
(1 row)

 spend_logs_to_rewrite 
-----------------------
                     6
(1 row)

          tbl           | rows_to_fold |   spend   
------------------------+--------------+-----------
 LiteLLM_DailyUserSpend |            6 | 0.0001173
 LiteLLM_DailyTeamSpend |            6 | 0.0001173
 LiteLLM_DailyTagSpend  |            0 |          
(3 rows)

ROLLBACK
  1. Apply, compare the per-key totals, then COMMIT:
$ psql "$DATABASE_URL" -v since=2026-01-01 -f backfill_part2.sql   # apply, then COMMIT once the totals match
BEGIN
SELECT 2
SELECT 3
UPDATE 6
INSERT 0 0
DELETE 6
INSERT 0 0
DELETE 6
INSERT 0 0
DELETE 0
          tbl           |           key            |      spend_before      | spend_after | requests_before | requests_after 
------------------------+--------------------------+------------------------+-------------+-----------------+----------------
 LiteLLM_DailyTagSpend  | cli-session-mateo-cli-qa |                7.6e-05 |     7.6e-05 |               2 |              2
 LiteLLM_DailyTeamSpend | cli-session-mateo-cli-qa | 0.00023085000000000003 |  0.00023085 |              12 |             12
 LiteLLM_DailyUserSpend | cli-session-mateo-cli-qa | 0.00023085000000000003 |  0.00023085 |              12 |             12
(3 rows)

 spend_logs_still_on_a_session_key 
-----------------------------------
                                 0
(1 row)

COMMIT
  1. The same usage routes now show one key for the user with all twelve requests:
## GET /spend/logs?start_date=2026-09-10&end_date=2026-09-11 (admin key)
{"cli-session-mateo-cli-qa": 0.00023085000000000003, "startTime": "2026-09-10", "spend": 0.00023085000000000003, "users": {"mateo-cli-qa": 0.00023085000000000003}}
{"startTime": "2026-09-11", "spend": 0, "users": {}}
## GET /user/daily/activity?start_date=2026-09-10&end_date=2026-09-10 (admin key)
date 2026-09-10 spend 0.00023085 requests 12
  api_key cli-session-mateo-cli-qa | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 12 | spend 0.00023085
## same route with login 1 token (what the CLI user sees)
date 2026-09-10 spend 0.00023085 requests 12
  api_key cli-session-mateo-cli-qa | key_alias cli-session-mateo-cli-qa | user_email mateo@berri.ai | team_id cli-qa-team | requests 12 | spend 0.00023085

Closing observations:

  • Both legs: same single instance, two workers, same two logins
  • Before: two 64-hex keys, one per login, each three requests
  • Before: alias and email show only because staging's fix(spend-tracking): recover key alias for session tokens from spend logs #40275 read-time recovery found them in the log window; the customer's v1.101.0-rc.1 has no such recovery
  • After: every new row lands on cli-session-mateo-cli-qa
  • After: alias, email, and team filled from the key and user row
  • Backfill: totals match, zero spend logs left on a session key
  • Daily aggregates flush a few seconds after the request

Type

🐛 Bug Fix

Caveats (if any)

Severe

  • Session spend keys change from a per-login hash to cli-session-<user_id>
    • History stays on the old hashes until the customer runs the backfill
    • BI queries that grouped by the hash now group by the alias
  • The backfill rewrites LiteLLM_SpendLogs.api_key in place and folds daily rows
    • One transaction, locks the rows it touches; run it off-peak, dry run first

Medium

  • Proof reads usage through the API routes, not a Usage page screenshot
    • LLM calls went through curl with real litellm-proxy login tokens, not a coding tool
  • user_api_key_hash in logging callbacks carries the alias for session requests
    • Deployment affinity, complexity-router session affinity, and adaptive-router identity key on it, so CLI logins of one user now share one affinity scope
    • The v3 rate limiter's post-call TPM counters for session requests move to the alias scope; session tokens carry no key limits, so nothing is enforced either way
  • A user in several teams gets no team_id in usage metadata for the alias
    • Spend rows and team views still carry the team the CLI login attached

Low

  • Folding daily rows keeps MIN(model_group) and MIN(request_id); both are informational
  • Daily aggregates queued at proxy stop are lost (pre-existing shutdown flush gap, seen on the before leg's tag rows)
  • The Usage page groups the alias under "Unassigned" for a multi-team user until a team is picked
  • CircleCI local_testing_part1 and llm_translation_testing are 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 them

Final 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)

…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.
@mateo-berri
mateo-berri requested a review from a team September 10, 2026 04:16
@codspeed

codspeed Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit6852_cli_session_spend_key (1d0dc56) with litellm_internal_staging (69245fe)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR attributes CLI session spend to the stable per-user session alias while retaining hashed identities for ordinary virtual keys

  • Applies the alias consistently across successful and failed spend logging paths
  • Recovers CLI session ownership, email, and unambiguous team metadata in usage views and exports
  • Preserves key redaction by accepting an alias only when it matches trusted authentication metadata
  • Adds regression coverage for unified endpoint logging, metadata recovery, redaction, and non-session keys

Confidence Score: 5/5

The 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

Important Files Changed

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

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.07692% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ellm/proxy/spend_tracking/key_metadata_recovery.py 96.87% 1 Missing ⚠️

📢 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.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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.

@mateo-berri mateo-berri added run-ci and removed run-ci labels Sep 10, 2026

@tin-berri tin-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@yuneng-berri
yuneng-berri deleted the branch main September 13, 2026 04:51
@mateo-berri mateo-berri reopened this Sep 13, 2026
@mateo-berri
mateo-berri changed the base branch from litellm_internal_staging to main September 13, 2026 05:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants