Skip to content

test(e2e): every spend row a virtual key writes joins its token across all write paths - #40465

Merged
mateo-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_e2e_spend_rows_join_virtual_key
Sep 10, 2026
Merged

mateo-berri merged 5 commits into
litellm_internal_stagingfrom
litellm_e2e_spend_rows_join_virtual_key

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

How it solves it:

  • New e2e test drives one aliased key through every spend write path
  • Asserts every row's api_key equals the key's token hash and carries the alias
  • Asserts /spend/logs and /user/daily/activity report the key with alias and email
  • Health-check rows must keep the literal service-account key, never a hash of it
  • The cost row written when a batch is retrieved in a terminal state joins the retrieving key too
  • A callback log replayed through POST /v1/rust_control_plane/logs, the writer an external gateway feeds, is the ninth path

User Flow

Before: an admin exporting usage per key after a proxy upgrade finds every request under a scrambled key with no alias or owner, and no CI stage had flagged it

  1. They send POST https://litellm-domain/key/generate with {"key_alias": "team-search", "user_id": "sam"} (sam has user_email set) and get back key and a 64-character token
  2. Their app calls POST https://litellm-domain/v1/chat/completions, POST https://litellm-domain/v1/messages, POST https://litellm-domain/v1/responses, POST https://litellm-domain/v1/embeddings, POST https://litellm-domain/gemini/v1beta/models/gemini-2.5-flash:generateContent, uploads a batch through POST https://litellm-domain/v1/files and POST https://litellm-domain/v1/batches, reads it back with GET https://litellm-domain/v1/batches/?provider=openai once it is terminal, and their gateway replays a finished call through POST https://litellm-domain/v1/rust_control_plane/logs, all with that key, all 200
  3. They send GET https://litellm-domain/spend/logs?api_key=sk-... and get []
  4. They send GET https://litellm-domain/user/daily/activity?api_key= and the api_keys breakdown has no entry for the token
  5. They send GET https://litellm-domain/spend/logs and find the requests keyed by a different 64-character hash with key_alias missing, so the export attributes the spend to nobody
  6. The upgrade had passed the Buildkite e2e stage because no test checks that join

After: the same upgrade turns the Buildkite e2e stage red before it ships, naming the write path whose rows stopped joining the key

  1. They send POST https://litellm-domain/key/generate with {"key_alias": "team-search", "user_id": "sam"} and get back key and a 64-character token
  2. Their app calls the same POST https://litellm-domain/v1/chat/completions, /v1/messages, /v1/responses, /v1/embeddings, /gemini/v1beta/models/gemini-2.5-flash:generateContent, /v1/files, /v1/batches, GET /v1/batches/?provider=openai, and POST /v1/rust_control_plane/logs routes with that key, all 200
  3. They send GET https://litellm-domain/spend/logs?api_key=sk-... and get one row per call, each with api_key equal to token and metadata.user_api_key_alias equal to team-search
  4. They send GET https://litellm-domain/user/daily/activity?api_key= and the breakdown lists the token with key_alias: team-search and user_email: sam@...
  5. A build that breaks any of that fails tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py with the write path and the offending api_key in the assertion message, so the release never reaches them

Relevant issues

Guards against the shape of #39568 and #39572

Linear ticket

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)

Screenshots / Proof of Fix

Setup shared by both legs: a proxy on a random free port (42270) with a fresh Postgres DB, store_model_in_db: true, PROXY_BATCH_POLLING_ENABLED=true, and config models gemini-2.5-flash, claude-haiku-4-5, openai-responses-codex (openai/gpt-5.3-codex), openai-text-embedding-3-small, gpt-4o-mini. Real provider spend on every leg. $M is the master key, $B is http://localhost:42270, $J is Content-Type: application/json. The "broken proxy" in both legs is the same 2-line mutation of the spend-row key redaction that v1.99.0 shipped in effect: every key, including the health-check service account, gets hashed once more before the row is written

Create the user with an email and the key with an alias:

curl -s $B/user/new -H "Authorization: Bearer $M" -H "$J" -d '{"user_id":"mat180-user","user_email":"mat180@example.com","user_role":"proxy_admin","auto_create_key":false}'
KEY=$(curl -s $B/key/generate -H "Authorization: Bearer $M" -H "$J" -d '{"key_alias":"mat180-alias","user_id":"mat180-user"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['key'])")
H="Authorization: Bearer $KEY"

Drive every write path with that key (the same commands the new test runs):

curl -s $B/chat/completions -H "$H" -H "$J" -d '{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"say hi"}],"max_tokens":8}' -o /dev/null -w 'chat %{http_code}\n'
curl -s $B/v1/messages -H "$H" -H "$J" -d '{"model":"claude-haiku-4-5","max_tokens":8,"messages":[{"role":"user","content":"say hi"}]}' -o /dev/null -w 'messages %{http_code}\n'
curl -s $B/v1/responses -H "$H" -H "$J" -d '{"model":"openai-responses-codex","input":"say hi"}' -o /dev/null -w 'responses %{http_code}\n'
curl -s $B/embeddings -H "$H" -H "$J" -d '{"model":"openai-text-embedding-3-small","input":"hello"}' -o /dev/null -w 'embeddings %{http_code}\n'
curl -s $B/queue/chat/completions -H "$H" -H "$J" -d '{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"say hi"}],"max_tokens":8,"priority":0}' -o /dev/null -w 'queue %{http_code}\n'
curl -s "$B/gemini/v1beta/models/gemini-2.5-flash:generateContent" -H "x-goog-api-key: $KEY" -H "$J" -d '{"contents":[{"parts":[{"text":"say hi"}]}],"generationConfig":{"maxOutputTokens":8}}' -o /dev/null -w 'passthrough %{http_code}\n'
FID=$(curl -s "$B/v1/files?model=gpt-4o-mini" -H "$H" -F purpose=batch -F "file=@batch.jsonl;type=application/jsonl" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
BID=$(curl -s "$B/v1/batches" -H "$H" -H "$J" -d "{\"input_file_id\":\"$FID\",\"endpoint\":\"/v1/chat/completions\",\"completion_window\":\"24h\",\"model\":\"gpt-4o-mini\"}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
curl -s -X POST "$B/v1/batches/$BID/cancel" -H "$H" -o /dev/null -w 'batch create+cancel %{http_code}\n'
curl -s "$B/health?model=gemini-2.5-flash" -H "Authorization: Bearer $M" -o /dev/null -w 'health %{http_code}\n'
sleep 25

Then read the rows back: GET $B/spend/logs?api_key=$KEY, GET $B/spend/logs filtered to rows carrying the alias or the token (printed as call_type, whether api_key equals the key's token, metadata.user_api_key_alias), the newest health-check row, and GET $B/user/daily/activity?start_date=2026-09-08&end_date=2026-09-10&api_key=<sha256 of $KEY>

Before (096984b)

Every write path on the broken proxy, existing suite green

  1. Ran the driver above against the mutated proxy
    chat 200
    messages 200
    responses 200
    embeddings 200
    queue 200
    passthrough 200
    batch create+cancel 200
    health 200
    
  2. Read the rows back
    key token (LiteLLM_VerificationToken.token) = f622a25e86cbb8262c7f58fe79799c4e2eddb5c4786c7398fe6a3023ab22b6ac
    /spend/logs?api_key=<key> rows: 0
    call_type              api_key == token alias                  email
    acompletion            False            mat180-mutated-alias   None  api_key=bddb00d0dc29cb7a5e0c...
    acompletion            False            mat180-mutated-alias   None  api_key=bddb00d0dc29cb7a5e0c...
    acreate_batch          False            mat180-mutated-alias   None  api_key=bddb00d0dc29cb7a5e0c...
    acreate_file           False            mat180-mutated-alias   None  api_key=bddb00d0dc29cb7a5e0c...
    aembedding             False            mat180-mutated-alias   None  api_key=bddb00d0dc29cb7a5e0c...
    anthropic_messages     False            mat180-mutated-alias   None  api_key=bddb00d0dc29cb7a5e0c...
    aresponses             False            mat180-mutated-alias   None  api_key=bddb00d0dc29cb7a5e0c...
    pass_through_endpoint  False            mat180-mutated-alias   None  api_key=bddb00d0dc29cb7a5e0c...
    daily activity: no api_keys breakdown for token
    
  3. tests/e2e/quota_management/spend_tracking/ at this commit has no test that asserts a spend row's api_key equals the key's token, so the e2e stage passes with every row unattributed

After (654afb5; 5db99ca only renames the coverage cell the batch test claims, 71cf1c7 only drops unused client code and makes the user teardown assert its response; the module re-ran against the head proxy at 71cf1c7, 5 passed in 91.55s)

Every write path on the broken proxy, new test red

  1. Ran the driver above against the same mutated proxy: same eight 200s, same /spend/logs?api_key=<key> rows: 0, same api_key == token False on every row, same missing daily activity entry
  2. Ran the new module against it
    pytest tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py -v
    test_every_write_path_row_joins_the_key FAILED
    E  AssertionError: spend rows whose api_key does not join LiteLLM_VerificationToken.token b987eb0a306b5da6...: [('chat_completions', 'acompletion', 'c4aac7346ffe636d...'), ('queue_chat_completions', 'acompletion', 'c4aac7346ffe636d...'), ('messages', 'anthropic_messages', 'c4aac7346ffe636d...'), ('responses', 'aresponses', 'c4aac7346ffe636d...'), ('embeddings', 'aembedding', 'c4aac7346ffe636d...'), ('gemini_passthrough', 'pass_through_endpoint', 'c4aac7346ffe636d...'), ('batch_file_upload', 'acreate_file', 'c4aac7346ffe636d...'), ('batch_create', 'acreate_batch', 'c4aac7346ffe636d...'), ('callback_replay', 'acompletion', 'c4aac7346ffe636d...')]
    test_spend_logs_by_key_return_every_row_with_the_alias FAILED
    E  AssertionError: /spend/logs?api_key= does not return 9 of 9 rows for the key: ['batch_create', 'batch_file_upload', 'callback_replay', 'chat_completions', 'embeddings', 'gemini_passthrough', 'messages', 'queue_chat_completions', 'responses']
    test_user_daily_activity_reports_alias_and_email FAILED
    E  AssertionError: /user/daily/activity?api_key=b987eb0a306b5da6... has no api_keys breakdown: the key's rows did not aggregate under its token
    test_health_check_rows_keep_the_service_account_key FAILED
    E  AssertionError: health-check rows keyed by something other than 'litellm-internal-health-check': [('sOehasjhKra1_uMP_MSP-AI', '2ee659761968def9...')]
    test_terminal_batch_cost_row_joins_the_retrieving_key FAILED
    E  AssertionError: batch cost rows that do not join the retrieving key's token b987eb0a306b5da6... with alias 'key-attribution-a4076a7a44f7': [('aretrieve_batch', 'c4aac7346ffe636d...', 'key-attribution-a4076a7a44f7')]
    5 failed in 295.84s
    

Every write path on the proxy at head, new test green

  1. Ran the driver above against the proxy at head
    chat 200
    messages 200
    responses 200
    embeddings 200
    queue 200
    passthrough 200
    batch create+cancel 200
    health 200
    
  2. Read the rows back
    key token (LiteLLM_VerificationToken.token) = 30f65bf0ae36a82d0672d416ed4e3c839f1b6834624c70fbb6f674a7aa5224fe
    /spend/logs?api_key=<key> rows: 8
    call_type              api_key == token alias                  email
    acompletion            True             mat180-head-alias      None  api_key=30f65bf0ae36a82d0672...
    acompletion            True             mat180-head-alias      None  api_key=30f65bf0ae36a82d0672...
    acreate_batch          True             mat180-head-alias      None  api_key=30f65bf0ae36a82d0672...
    acreate_file           True             mat180-head-alias      None  api_key=30f65bf0ae36a82d0672...
    aembedding             True             mat180-head-alias      None  api_key=30f65bf0ae36a82d0672...
    anthropic_messages     True             mat180-head-alias      None  api_key=30f65bf0ae36a82d0672...
    aresponses             True             mat180-head-alias      None  api_key=30f65bf0ae36a82d0672...
    pass_through_endpoint  True             mat180-head-alias      None  api_key=30f65bf0ae36a82d0672...
    health row api_key=litellm-internal-health-check  readable=True
    daily activity 30f65bf0ae36a82d... matches token {'key_alias': 'mat180-head-alias', 'team_id': None, 'user_email': 'mat180-head@example.com'} requests 8
    
  3. Ran the new module against it
    pytest tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py -v
    test_every_write_path_row_joins_the_key PASSED
    test_spend_logs_by_key_return_every_row_with_the_alias PASSED
    test_user_daily_activity_reports_alias_and_email PASSED
    test_health_check_rows_keep_the_service_account_key PASSED
    test_terminal_batch_cost_row_joins_the_retrieving_key PASSED
    5 passed in 43.34s
    

The two writers added after review, on the proxy at head

Replay one finished call through the callback log writer with the key (the payload names the key's own token hash, the way an external gateway reports it), and retrieve a batch OpenAI failed at validation by its raw provider id with the same key. $TOKEN is the sha256 of $KEY, the value /key/generate returns as token

NOW=$(date +%s)
curl -s $B/v1/rust_control_plane/logs -H "$H" -H "$J" -d "{\"records\":[{\"status\":\"success\",\"standard_logging_payload\":{\"id\":\"callback-replay-proof-$NOW\",\"litellm_call_id\":\"callback-replay-proof-$NOW\",\"model\":\"gemini-2.5-flash\",\"call_type\":\"acompletion\",\"startTime\":$((NOW-1)),\"endTime\":$NOW,\"response_cost\":0.0001,\"prompt_tokens\":5,\"completion_tokens\":1,\"total_tokens\":6,\"metadata\":{\"user_api_key_hash\":\"$TOKEN\",\"user_api_key_alias\":\"mat180-v2-alias\",\"user_api_key_user_id\":\"mat180-v2-user\"}}}]}" -w ' replay %{http_code}\n'
printf '%s\n' '{"custom_id":"req-1","method":"POST","url":"/v1/embeddings","body":{"model":"gpt-4o-mini","input":"ping"}}' > mismatch.jsonl
FID=$(curl -s "$B/v1/files?model=gpt-4o-mini" -H "$H" -F purpose=batch -F "file=@mismatch.jsonl;type=application/jsonl" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
UID_=$(curl -s "$B/v1/batches" -H "$H" -H "$J" -d "{\"input_file_id\":\"$FID\",\"endpoint\":\"/v1/chat/completions\",\"completion_window\":\"24h\",\"model\":\"gpt-4o-mini\"}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
RAW=$(python3 -c "import sys,base64; e=sys.argv[1].removeprefix('batch_'); print(base64.urlsafe_b64decode(e+'='*(-len(e)%4)).decode().removeprefix('litellm:').split(';',1)[0])" "$UID_")
for i in $(seq 1 30); do S=$(curl -s "$B/v1/batches/$RAW?provider=openai" -H "$H" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])"); echo "$S"; case $S in failed|completed|cancelled|expired) break;; esac; sleep 5; done
sleep 20
curl -s "$B/spend/logs?request_id=callback-replay-proof-$NOW" -H "Authorization: Bearer $M"
curl -s "$B/spend/logs?request_id=${RAW}_batch_cost" -H "Authorization: Bearer $M"

Output (rows printed as call_type, whether api_key equals $TOKEN, metadata.user_api_key_alias, spend)

{"processed":1,"failed":0,"failures":[]} replay 200
validating
failed
replay row  acompletion      api_key==token True  alias mat180-v2-alias  spend 0.0001
batch cost  aretrieve_batch  api_key==token True  alias mat180-v2-alias  spend 0.0

The validating retrieve wrote nothing; the first retrieve that saw the batch failed wrote the single <provider batch id>_batch_cost row, keyed by the retrieving key's token with its alias. A completed batch retrieved the same way on this proxy booked spend 2.1e-06 under the same shape, so the writer is one and the same for every terminal status

Type

✅ Test

Caveats (if any)

The batch cost leg asserts on a batch OpenAI fails at validation, so the row it checks is a $0 aretrieve_batch row. The completed, positive-cost case runs through the same retrieve writer (shown above on this proxy), but a completed batch is out of a single run's reach: OpenAI's completion window is 24h, and GET /v1/batches is served from LiteLLM_ManagedObjectTable whenever the managed files hook is loaded, so a stack booted with a fresh Postgres per build (both Buildkite e2e stacks) never lists an earlier run's batch. That also means the CheckBatchCost poller's own row, which bills a unified batch id against the key that created it, has no batch to bill in-run within the poll budget, so it is not asserted; it is registered as its own P1 cell, quota_management.spend_tracking.key_attribution.poller_batch_cost_joins_creating_key, which no test claims, so the coverage dashboard shows the gap instead of hiding it behind the retrieve leg (follow-up ticket filed). The batches suite's TestBatchTerminalState baton relies on the same list and has the same blind spot; that is a separate ticket. Until that follow-up lands, a regression confined to the poller writer (CheckBatchCost, which only picks up batches that reached a completed status) leaves this suite green; the proof's mutation re-hashes every row, which is wider than a poller-only break

The nine paths are driven once by a class-scoped fixture, so a provider failure on any one of them errors the four tests that read its rows instead of failing only that path, and a row the proxy never writes costs the 120s per-request-id poll budget before the join assertion names it

Spend writers this stack cannot drive stay uncovered: MCP tool-call rows (needs an MCP server registered), web search interception rows (needs the interception config), semantic text index rows, and realtime sessions. The health-check leg covers the service-account writer, and the callback replay covers the third v1.99.0 writer

QA runbook

Prerequisites: a proxy with a database, store_model_in_db: true, PROXY_BATCH_POLLING_ENABLED=true, and credentials for Gemini (gemini-2.5-flash), Anthropic (claude-haiku-4-5), and OpenAI (openai-responses-codex, openai-text-embedding-3-small, openai-gpt-4o-mini; the suite registers missing driver models itself). /queue/chat/completions only accepts a key whose user is proxy_admin and needs priority in the body, and the Gemini passthrough takes the key in x-goog-api-key, not Authorization

  • tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py::TestKeyAttribution::test_every_write_path_row_joins_the_key - every spend row from every write path carries the key's own token hash and alias

    • POST /user/new with the master key: {"user_id":"qa-attr","user_email":"qa-attr@example.com","user_role":"proxy_admin","auto_create_key":false}
    • POST /key/generate with {"key_alias":"qa-attr","user_id":"qa-attr"} and keep both key and token from the response
    • With that key send POST /chat/completions (gemini-2.5-flash), POST /queue/chat/completions (same body plus "priority": 0), POST /v1/messages (claude-haiku-4-5), POST /v1/responses (openai-responses-codex), POST /embeddings (openai-text-embedding-3-small), POST /gemini/v1beta/models/gemini-2.5-flash:generateContent with x-goog-api-key: <key>, POST /v1/files?model=openai-gpt-4o-mini with a one-line batch jsonl, and POST /v1/batches with that file id; expect 200 on all eight and keep each response's id (embeddings and passthrough: the x-litellm-call-id response header)
    • POST /v1/rust_control_plane/logs with that key and one records[] entry whose standard_logging_payload names a fresh id, model, response_cost, and metadata.user_api_key_hash equal to token; expect {"processed":1,"failed":0}
    • GET /spend/logs?request_id= for each of the nine ids and expect at least one row whose api_key equals token exactly and whose metadata.user_api_key_alias is qa-attr
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py::TestKeyAttribution::test_spend_logs_by_key_return_every_row_with_the_alias - filtering spend logs by the raw key returns all nine rows, every one with the alias

    • After the calls above, GET /spend/logs?api_key= with the master key
    • Expect the nine request ids all present and metadata.user_api_key_alias equal to qa-attr on every row (no null, no other alias)
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py::TestKeyAttribution::test_user_daily_activity_reports_alias_and_email - the daily activity report lists the key under its token with the alias and the owner's email

    • GET /user/daily/activity?start_date=&end_date=&api_key= with the master key
    • Expect results[].breakdown.api_keys[<token>] present with metrics.api_requests of at least 9, metadata.key_alias equal to qa-attr, and metadata.user_email equal to qa-attr@example.com
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py::TestKeyAttribution::test_health_check_rows_keep_the_service_account_key - a health check's spend row is keyed by the literal service account name, never by a hash of it

    • GET /health?model=gemini-2.5-flash with the master key and expect a healthy response
    • GET /spend/logs/v2 for the last minute and find the row whose request_tags contains litellm-internal-health-check
    • Expect that row's api_key to be exactly litellm-internal-health-check
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky
  • tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py::TestKeyAttribution::test_terminal_batch_cost_row_joins_the_retrieving_key - the cost row written when a batch is retrieved in a terminal state joins the retrieving key

    • The batch created above has one JSONL line aimed at /v1/embeddings under a /v1/chat/completions batch, so OpenAI fails it at validation within seconds; decode its provider id from the unified id (base64url of litellm:batch_<provider id>;model,<name> after the batch_ prefix)
    • GET /v1/batches/?provider=openai with the same key every 5s until status is failed (the test allows 120s)
    • GET /spend/logs?request_id=_batch_cost and expect exactly aretrieve_batch rows, every one with api_key equal to token and metadata.user_api_key_alias equal to qa-attr
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky

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

Link to Devin session: https://app.devin.ai/sessions/5b21193c1cd64d7f9d94b0033ca087fe
Open in Devin Desktop: https://app.devin.ai/desktop/session/5b21193c1cd64d7f9d94b0033ca087fe?variant=devin
Requested by: @mateo-berri


Note

Low Risk
Changes are limited to e2e tests, client models, and coverage docs; no proxy or spend-tracking production code is modified.

Overview
Adds e2e coverage for virtual-key spend attribution, guarding regressions like #39568/#39572 where spend rows stop joining LiteLLM_VerificationToken.token and usage exports show unattributed key-hash-* rows.

A new test_key_attribution_e2e.py drives one aliased key (with an owning user email) through nine spend write paths—chat, queued chat, messages, responses, embeddings, Gemini passthrough, batch file/create, and rust control-plane callback replay—and asserts each row’s api_key equals /key/generate’s token and carries metadata.user_api_key_alias. Separate cases cover /spend/logs?api_key=, /user/daily/activity (alias + email), health rows staying on litellm-internal-health-check, and inline batch cost on terminal retrieve ({provider_id}_batch_cost).

Harness updates: SpendClient gains typed helpers for those routes and daily-activity polling; models.py adds KeyGenerateResponse.token and spend-log alias metadata; spend-tracking coverage registry documents five key_attribution cells (poller batch cost remains an explicit uncovered gap).

Reviewed by Cursor Bugbot for commit 71cf1c7. Bugbot is set up for automated code reviews on this repo. 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 #39568 and
#39572) now fails the Buildkite e2e stage naming the write path

Resolves MAT-180
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds live end-to-end regression coverage ensuring spend rows retain virtual-key attribution across nine write paths and usage-reporting APIs.

  • Exercises chat, queued chat, messages, responses, embeddings, Gemini passthrough, file upload, batch creation, and callback replay.
  • Verifies key hashes, aliases, owner email aggregation, and health-check service-account identity.
  • Separately covers inline terminal-batch retrieval attribution while explicitly registering poller accounting as an uncovered gap.
  • Tightens user teardown validation and removes unused batch-list client code.

Confidence Score: 5/5

The PR appears safe to merge; no actionable defect or outstanding previous finding remains.

The recent cleanup only removes unused helpers and makes teardown enforce the existing integer /user/delete response contract. The earlier callback concern was correctly withdrawn, and the completed-batch concern is addressed by narrowing the claimed retrieve coverage and explicitly exposing poller attribution as an unclaimed coverage gap.

Important Files Changed

Filename Overview
tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py Adds comprehensive live spend-attribution checks across supported write paths, reporting APIs, health checks, and terminal batch retrieval.
tests/e2e/quota_management/spend_tracking/spend_e2e_client.py Adds typed helpers for the new attribution scenarios, validates user deletion during teardown, and removes unused batch-list helpers.
tests/e2e/coverage_registry/quota_management.yaml Registers the new attribution coverage and accurately leaves poller-based completed-batch accounting unclaimed.
tests/e2e/models.py Extends E2E response models with the generated token and spend-row key alias required by the assertions.
tests/e2e/quota_management/spend_tracking/conftest.py Registers the OpenAI batch model needed by the new live batch scenario.
tests/e2e/CLAUDE.md Documents the new key-attribution coverage vocabulary and separate batch-accounting assertions.

Reviews (5): Last reviewed commit: "test(e2e): drop the unused batch list cl..." | Re-trigger Greptile

Comment thread tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Devin e2e QA

Tested at: 3020a13
Session: https://app.devin.ai/sessions/5b21193c1cd64d7f9d94b0033ca087fe

Setup shared by both legs: a proxy on a random free port (20656) with a fresh Postgres DB per leg, store_model_in_db: true, PROXY_BATCH_POLLING_ENABLED=true, and config models gemini-2.5-flash, claude-haiku-4-5, openai-responses-codex (openai/gpt-5.3-codex), openai-text-embedding-3-small, openai-gpt-4o-mini, gpt-4o-mini, all on real Gemini, Anthropic, and OpenAI keys. $M is the master key, $B is http://localhost:20656, $J is Content-Type: application/json. Both legs run the same driver, an unmodified proxy at each commit

curl -s $B/user/new -H "Authorization: Bearer $M" -H "$J" -d '{"user_id":"mat180-<leg>-user","user_email":"mat180-<leg>@example.com","user_role":"proxy_admin","auto_create_key":false}'
KR=$(curl -s $B/key/generate -H "Authorization: Bearer $M" -H "$J" -d '{"key_alias":"mat180-<leg>-alias","user_id":"mat180-<leg>-user"}')
KEY=$(echo "$KR" | python3 -c "import sys,json; print(json.load(sys.stdin)['key'])"); TOKEN=$(echo "$KR" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
H="Authorization: Bearer $KEY"
curl -s $B/chat/completions -H "$H" -H "$J" -d '{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"say hi"}],"max_tokens":8}' -o /dev/null -w 'chat %{http_code}\n'
curl -s $B/v1/messages -H "$H" -H "$J" -d '{"model":"claude-haiku-4-5","max_tokens":8,"messages":[{"role":"user","content":"say hi"}]}' -o /dev/null -w 'messages %{http_code}\n'
curl -s $B/v1/responses -H "$H" -H "$J" -d '{"model":"openai-responses-codex","input":"say hi"}' -o /dev/null -w 'responses %{http_code}\n'
curl -s $B/embeddings -H "$H" -H "$J" -d '{"model":"openai-text-embedding-3-small","input":"hello"}' -o /dev/null -w 'embeddings %{http_code}\n'
curl -s $B/queue/chat/completions -H "$H" -H "$J" -d '{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"say hi"}],"max_tokens":8,"priority":0}' -o /dev/null -w 'queue %{http_code}\n'
curl -s "$B/gemini/v1beta/models/gemini-2.5-flash:generateContent" -H "x-goog-api-key: $KEY" -H "$J" -d '{"contents":[{"parts":[{"text":"say hi"}]}],"generationConfig":{"maxOutputTokens":8}}' -o /dev/null -w 'passthrough %{http_code}\n'
FID=$(curl -s "$B/v1/files?model=gpt-4o-mini" -H "$H" -F purpose=batch -F "file=@batch.jsonl;type=application/jsonl" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
BID=$(curl -s "$B/v1/batches" -H "$H" -H "$J" -d "{\"input_file_id\":\"$FID\",\"endpoint\":\"/v1/chat/completions\",\"completion_window\":\"24h\",\"model\":\"gpt-4o-mini\"}" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
curl -s -X POST "$B/v1/batches/$BID/cancel" -H "$H" -o /dev/null -w 'batch create+cancel %{http_code}\n'
curl -s "$B/health?model=gemini-2.5-flash" -H "Authorization: Bearer $M" -o /dev/null -w 'health %{http_code}\n'
sleep 25

Read-back after the driver: GET $B/spend/logs?api_key=$KEY printed as call_type, whether api_key equals $TOKEN, metadata.user_api_key_alias; the newest litellm-internal-health-check-tagged row from GET $B/spend/logs; and GET $B/user/daily/activity?start_date=<yesterday>&end_date=<tomorrow>&api_key=$TOKEN

Before (096984b)

Every write path by hand, rows read back

  1. Ran the driver against the merge-base proxy
    {'user_id': 'mat180-before-user', 'user_email': 'mat180-before@example.com', 'user_role': 'proxy_admin'}
    key=sk-Iq2K7... token=6e208c6dc47e61022705907cd3d8217622bec7c39ee63a68a2e66c22e6560095
    chat 200
    messages 200
    responses 200
    embeddings 200
    queue 200
    passthrough 200
    batch create+cancel 200
    health 200
    
  2. Read the rows back
    $ GET $B/spend/logs?api_key=$KEY
    rows: 8
    call_type              api_key == token alias
    acompletion            True             mat180-before-alias  api_key=6e208c6dc47e61022705...
    acompletion            True             mat180-before-alias  api_key=6e208c6dc47e61022705...
    acreate_batch          True             mat180-before-alias  api_key=6e208c6dc47e61022705...
    acreate_file           True             mat180-before-alias  api_key=6e208c6dc47e61022705...
    aembedding             True             mat180-before-alias  api_key=6e208c6dc47e61022705...
    anthropic_messages     True             mat180-before-alias  api_key=6e208c6dc47e61022705...
    aresponses             True             mat180-before-alias  api_key=6e208c6dc47e61022705...
    pass_through_endpoint  True             mat180-before-alias  api_key=6e208c6dc47e61022705...
    $ GET $B/spend/logs (newest health-check row)
    health row api_key=litellm-internal-health-check
    $ GET $B/user/daily/activity?start_date=...&end_date=...&api_key=$TOKEN
    daily activity 6e208c6dc47e6102... matches token {'key_alias': 'mat180-before-alias', 'team_id': None, 'user_email': 'mat180-before@example.com'} requests 8
    

The new module against the proxy

  1. The module does not exist at the merge base, and no spend_tracking e2e test reads api_key off a row, so nothing would catch a regression here
    $ ls tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py
    ls: cannot access 'tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py': No such file or directory
    $ grep -rn "api_key" tests/e2e/quota_management/spend_tracking/test_*.py | grep -c "row.api_key\|\.api_key =="
    0
    

After (3020a13)

Every write path by hand, rows read back

  1. Ran the driver against the branch-tip proxy
    {'user_id': 'mat180-after-user', 'user_email': 'mat180-after@example.com', 'user_role': 'proxy_admin'}
    key=sk-eXCUe... token=0c79686274453850b06f1311b5cb81dc85a3c45effd2a6eff52b3e39f9f2bc33
    chat 200
    messages 200
    responses 200
    embeddings 200
    queue 200
    passthrough 200
    batch create+cancel 200
    health 200
    
  2. Read the rows back
    $ GET $B/spend/logs?api_key=$KEY
    rows: 8
    call_type              api_key == token alias
    acompletion            True             mat180-after-alias  api_key=0c79686274453850b06f...
    acompletion            True             mat180-after-alias  api_key=0c79686274453850b06f...
    acreate_batch          True             mat180-after-alias  api_key=0c79686274453850b06f...
    acreate_file           True             mat180-after-alias  api_key=0c79686274453850b06f...
    aembedding             True             mat180-after-alias  api_key=0c79686274453850b06f...
    anthropic_messages     True             mat180-after-alias  api_key=0c79686274453850b06f...
    aresponses             True             mat180-after-alias  api_key=0c79686274453850b06f...
    pass_through_endpoint  True             mat180-after-alias  api_key=0c79686274453850b06f...
    $ GET $B/spend/logs (newest health-check row)
    health row api_key=litellm-internal-health-check
    $ GET $B/user/daily/activity?start_date=...&end_date=...&api_key=$TOKEN
    daily activity 0c79686274453850... matches token {'key_alias': 'mat180-after-alias', 'team_id': None, 'user_email': 'mat180-after@example.com'} requests 8
    

The new module against the proxy

  1. Ran the new module against the same proxy (real provider calls, fresh DB)
    $ LITELLM_PROXY_URL=http://localhost:20656 LITELLM_MASTER_KEY=sk-1234 uv run --no-sync pytest tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py -v
    tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py::TestKeyAttribution::test_every_write_path_row_joins_the_key PASSED
    tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py::TestKeyAttribution::test_spend_logs_by_key_return_every_row_with_the_alias PASSED
    tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py::TestKeyAttribution::test_user_daily_activity_reports_alias_and_email PASSED
    tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py::TestKeyAttribution::test_health_check_rows_keep_the_service_account_key PASSED
    tests/e2e/quota_management/spend_tracking/test_key_attribution_e2e.py::TestKeyAttribution::test_completed_batch_cost_row_joins_the_key PASSED
    ======================== 5 passed in 310.95s (0:05:10) =========================
    
    test_completed_batch_cost_row_joins_the_key passed on the documented cold start (a fresh DB has no completed marker batch yet)

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

Stale Bugbot comment from a previous run.

…ve the callback replay writer

The completed marker batch is now retrieved by its raw provider id with this
run's key, so the proxy prices it inline and the {provider_batch_id}_batch_cost
row must join that key's token and alias. The CheckBatchCost poller only bills
batches it created in the same database, which a stack booted fresh per run
never holds for a completed marker, so the old unified-id assertion had no row
to find. Every run also replays one callback log through
POST /v1/rust_control_plane/logs, the third spend writer, and asserts its row
joins the key like the eight request paths
…d of a cross-run baton

The batch list is served from LiteLLM_ManagedObjectTable whenever the managed
files hook is loaded, and the Buildkite e2e stacks bundle a fresh Postgres per
build, so a prior run's marker batch is never listed and the baton could only
ever pass vacuously. Each run now creates a batch OpenAI fails at validation
within seconds, retrieves it by its raw provider id with the same key until it
is failed, and asserts the {provider_batch_id}_batch_cost row that retrieve
writes joins the key's token hash and alias
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

Comment on lines +168 to +172
metadata=CallbackLogMetadata(
user_api_key_hash=identity.token,
user_api_key_alias=identity.alias,
user_api_key_user_id=identity.user_id,
),

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.

P2 Callback check is self-fulfilling

The callback replay supplies the expected token hash and alias in its payload, then checks that those same values appear in the spend row. The endpoint copies these payload fields into spend attribution instead of deriving them from the authenticated key, so this test stays green even if authenticated-key attribution is broken. As a result, it does not provide the claimed regression coverage for this write path. Exercise a payload/auth mismatch or assert behavior that the request did not provide verbatim.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

The proxy's hash handling still owns this row's api_key: the mutated proxy rehashes it and this leg goes red, so it is not self-fulfilling

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.

You're right for the regression this test is intended to guard. The callback payload supplies the expected token, but the persisted spend row still passes through the proxy's normal key-handling/redaction path; a mutation that re-hashes api_key changes identity.token into a different value, so test_every_write_path_row_joins_the_key fails. The alias assertion also verifies the callback metadata survives persistence. My original comment overstated the issue: this test does not prove the endpoint derives attribution from authentication rather than trusting payload metadata, but it does provide valid coverage for the row-key rehash regression described by MAT-180. I’m withdrawing the finding.

Comment on lines +385 to +393
def test_terminal_batch_cost_row_joins_the_retrieving_key(self, client: SpendClient, driven: DrivenKey) -> None:
provider_batch_id: Final = _provider_batch_id(_driven_batch_id(driven))
fetched: Final = _await_terminal_batch(client, driven.identity.key, provider_batch_id)
assert fetched.status == "failed", (
f"endpoint-mismatched batch {provider_batch_id} is {fetched.status!r} after "
f"{FAILED_BATCH_POLL_SECONDS:.0f}s, so its terminal cost row cannot be asserted"
)
cost_request_id: Final = f"{provider_batch_id}_batch_cost"
rows: Final = client.proxy.poll_logs_for_request_id(cost_request_id)

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.

P2 Completed batch coverage was lost

This test now creates a batch that must fail and checks inline accounting from a raw-ID retrieve. That is a different path from completed, positive-cost batch accounting, so a regression limited to completed-batch or poller attribution can pass this P1 coverage cell. Keep separate completed-batch coverage, or register this narrower failed-retrieve scenario without using it as a replacement.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

The retrieve writer and the CheckBatchCost poller are two different spend
writers, and the in-run failed batch only proves the first. Split the
key_attribution batch cell in two: retrieve_batch_cost_joins_retrieving_key,
which test_terminal_batch_cost_row_joins_the_retrieving_key claims, and
poller_batch_cost_joins_creating_key, which no test claims yet and so shows
up on the coverage dashboard as a P1 gap instead of hiding behind the
retrieve leg. The rationale records why one run cannot hand the poller a
completed batch on a stack that boots a fresh Postgres per build.
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

…r teardown miss

The failed-batch redesign left list_batches, BatchList, BatchListQuery and
the batch object's metadata and created_at fields with no caller, and they
duplicated the batches suite's own client. delete_user discarded its result,
so a user that outlived the class fixture went unnoticed; unwrap turns that
into a teardown error like delete_key already does.
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

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 71cf1c7. Configure here.

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

@mateo-berri
mateo-berri merged commit 9839bfc into litellm_internal_staging Sep 10, 2026
80 of 82 checks passed
@mateo-berri
mateo-berri deleted the litellm_e2e_spend_rows_join_virtual_key branch September 10, 2026 02:25

This branch had an error being deployed

1 failed deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant