Skip to content

fix(spend_logs): store litellm_call_id and match it in request_id lookups - #39068

Merged
mateo-berri merged 16 commits into
litellm_internal_stagingfrom
litellm_spend_log_request_id_call_id
Sep 13, 2026
Merged

mateo-berri merged 16 commits into
litellm_internal_stagingfrom
litellm_spend_log_request_id_call_id

Conversation

@mateo-berri

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

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • The x-litellm-call-id header never finds a success spend row
  • Success rows are keyed by the upstream provider response id
  • /v1/responses rows are unfindable by any client-visible id

How it solves it:

  • New nullable, indexed litellm_call_id column on LiteLLM_SpendLogs
  • Spend writes store the call id alongside the provider response id
  • Every request_id lookup surface now matches either id, the logs page ?log_id= deep link included
  • The call id is client-supplied and not unique, so a non-admin id lookup only returns rows the caller may view and answers 403 when nothing visible matches; that row scope covers every non-admin role that reaches the logs list (org admins and allowed_routes keys included, not only internal users), so a colliding foreign row can neither leak nor turn the caller's own lookup into a 403
  • A client-supplied call id longer than 256 characters (or empty) is replaced by a generated one, so a huge header can no longer make Postgres reject the spend row; the response header always carries the id that was stored
  • The guardrails monitor's log drawer opens the row whose request_id is the clicked id, so a client-set call id colliding with it cannot swap another request into the drawer
  • The logs page ?log_id= deep link and any id-filtered list, plain or session-grouped, put the exact request_id row first, so a newer request carrying that id as its call id cannot take its place
  • The request detail view resolves the caller's own row before asking a cold-storage logger, so a call id colliding with another tenant's provider id no longer blocks the caller's own row, and the logger is asked for the stored provider id it keys by

User Flow

Before: a developer saves the x-litellm-call-id response header, and looking the request up with it returns nothing

  1. They send POST https://litellm-domain/v1/chat/completions and read x-litellm-call-id: 7b3e1d99-8d5a-4e51-b186-80d0bed7c257 off the response headers
  2. They call GET https://litellm-domain/spend/logs?request_id=7b3e1d99-8d5a-4e51-b186-80d0bed7c257 and get [], no matter how long they wait
  3. The row exists only under the provider's own response id (the body id, e.g. DVOWavnjJ46kq8YP67TZ6QE); for /v1/responses that raw id appears nowhere in the client-visible response, so those rows cannot be looked up at all
  4. Opening https://litellm-domain/ui/logs?log_id=7b3e1d99-8d5a-4e51-b186-80d0bed7c257 shows the plain logs list with no request drawer

After: the same header value finds the row on every lookup surface

  1. They send POST https://litellm-domain/v1/chat/completions and read x-litellm-call-id: 7b3e1d99-8d5a-4e51-b186-80d0bed7c257 off the response headers
  2. They call GET https://litellm-domain/spend/logs?request_id=7b3e1d99-8d5a-4e51-b186-80d0bed7c257 and get the spend row for that request
  3. The same id also works on the logs page lookup (https://litellm-domain/ui/?page=logs) and its request-details view, /v1/responses requests included, and old rows stay findable by the provider response id
  4. Opening https://litellm-domain/ui/logs?log_id=7b3e1d99-8d5a-4e51-b186-80d0bed7c257 opens that request's drawer
  5. An internal (non-admin) user who looks up an id that also matches another tenant's row sees only their own row on the logs page lookup, and an id that matches nothing of theirs answers HTTP 403 on the list lookup and the request details view alike

Relevant issues

Related to #25952 (covers only client-supplied request ids, not the ids LiteLLM generates)

Linear ticket

Resolves LIT-6302

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

Setup shared by both runs: proxy booted with --num_workers 2 for the before run and --num_workers 1 for the after run, a fresh Postgres database per run, gemini-3.8-flash -> vertex_ai/gemini-3.8-flash (vertex_location: global) plus text-embedding-005-lit6302 -> vertex_ai/text-embedding-005, real Vertex AI calls, store_prompts_in_spend_logs: true. Requests use the master key unless a step names a user key. The before proxy runs the merge base 4049a075bd, the after proxy runs this PR's tip.

Before (4049a07)

Each of the four shapes returned HTTP 200 with an x-litellm-call-id header, and none of those ids resolved anything on any surface:

[chat]        POST /v1/chat/completions -> HTTP 200 x-litellm-call-id=aedc57cb-1248-4502-98fd-11fc577395f0 body_id=VYGYaozsML2K9LsPlNSB2Qs
[chat-stream] POST /v1/chat/completions -> HTTP 200 x-litellm-call-id=3cf3aefb-06fe-4e72-b5b4-7b21b6919cfe body_id=WYGYapzcBMSh9LsP3cCrkAQ
[messages]    POST /v1/messages         -> HTTP 200 x-litellm-call-id=b27a44ac-5d5c-4382-8ce1-09a7ee82d3ac body_id=WoGYau-3LvyR9LsP5Jjm4AI
[responses]   POST /v1/responses        -> HTTP 200 x-litellm-call-id=ccac5a6b-afe5-4b0c-bfbc-5943435e1ede body_id=resp_LHHnhN62s1n3WPI3fKN6fbiI9LQZy14qRNYL3s0d0-8Dtycz...
GET /spend/logs?request_id=aedc57cb-1248-4502-98fd-11fc577395f0    -> still [] after 90s
GET /spend/logs/ui?request_id=aedc57cb-1248-4502-98fd-11fc577395f0 -> HTTP 200 total=0 rows=[]
GET /spend/logs/ui/aedc57cb-1248-4502-98fd-11fc577395f0            -> HTTP 200 null
(identical [] / total=0 / null for 3cf3aefb..., b27a44ac..., ccac5a6b...)
GET /spend/logs?request_id=VYGYaozsML2K9LsPlNSB2Qs -> found at t=0s: [{"request_id":"VYGYaozsML2K9LsPlNSB2Qs","user":"default_user_id","call_type":"acompletion","model":"vertex_ai/gemini-3.8-flash","spend":0.00099}]

The provider-id lookup is the only one that works, and /v1/messages and /v1/responses expose no id that matches the stored one.

Logs page deep link (dashboard build without this PR's panel change, pointed at the after proxy so the backend already answers the call id): /ui/logs?log_id=7d9a2d5d-41e1-4339-8272-b9067fb587d4 renders the full list (93 rows) with no request drawer, while typing the same id into the page's search box does list the row IYeYatSdKoTcodAP_p2BwAI ("Showing 1-1 of 1"). Screenshots: lit6302-before-ui-deeplink-callid.png, lit6302-before-ui-search-callid.png

After (55853c1)

Boot applied 20260831120000_add_litellm_call_id_spend_logs then 20260831120001_spend_logs_litellm_call_id_index (the CREATE INDEX CONCURRENTLY migration), no CONCURRENTLY error, no INVALID index.

Feature flow (master key)

Every x-litellm-call-id resolves on all three surfaces, /spend/logs/ui/<id> returns the row whose response.id is the provider id, and the provider-id lookup still works:

[chat] POST /v1/chat/completions -> HTTP 200 x-litellm-call-id=cc861aa5-2ab8-4a20-a345-acd5adbc2347 body_id=F_SYauTtENKK6tkPm_Ke4Qo
[chat-stream] POST /v1/chat/completions -> HTTP 200 x-litellm-call-id=4e917876-1597-4e26-b5f6-76f8ccbef0dd body_id=GvSYaur6Eef26tkP-qH-wAY
[messages] POST /v1/messages -> HTTP 200 x-litellm-call-id=0da707e2-e54a-4543-a013-5a8c0aea0100 body_id=HPSYapOqOrSAodAP2PKM-Q0
[responses] POST /v1/responses -> HTTP 200 x-litellm-call-id=dd28a280-1d32-403b-9bac-ca2482cadfe0 body_id=resp_D0c3DZOMceOf6rbV2mCrxWDF1KR0qFwwwr0ljs7Zhv2B-qJ-IbpnjPqHnYEKi8GeFdZy5TuxjGfIHiuJoDLYlgNh86IErGuH4CH0AKhKGHpKja-vCCDF4I2_XJRiewIMe1ljEYHlBfNCCNzcIQSzxmb-S-cXpOE8OAxHS_QuRkNbdXMU4TH_obtCN5jb5rD9BH0vDc0QWZOg0fDLlVnafc9DooILI72_E43n6hpnifAqRl6TgPAik2Op-c1WBrhZK1-Aek220pfF8xrpWLZbuABV3Yw8jz_slmDFCR-bWuEzdIYMQ3KKLuC0XpQnS52xA5QEcvdYr21NV8bJ9R8aGw-q9pk3X1i6HJI_xcRK1BuS4iNV7gcOIvw_lKRZpT4nSFY-_mINn2gk4IN4oTCwViSn_hmzsg6vo1k=
--- chat: lookups by x-litellm-call-id cc861aa5-2ab8-4a20-a345-acd5adbc2347
[master] GET /spend/logs?request_id=cc861aa5-2ab8-4a20-a345-acd5adbc2347 -> found at t=5s: [{"request_id":"F_SYauTtENKK6tkPm_Ke4Qo","litellm_call_id":"cc861aa5-2ab8-4a20-a345-acd5adbc2347","user":"default_user_id","call_type":"acompletion","model":"vertex_ai/gemini-3.8-flash","spend":0.001005}]
[master] GET /spend/logs/ui?request_id=cc861aa5-2ab8-4a20-a345-acd5adbc2347 -> HTTP 200 total=1 rows=[{"request_id":"F_SYauTtENKK6tkPm_Ke4Qo","litellm_call_id":"cc861aa5-2ab8-4a20-a345-acd5adbc2347","user":"default_user_id","call_type":"acompletion"}]
[master] GET /spend/logs/ui/cc861aa5-2ab8-4a20-a345-acd5adbc2347 -> HTTP 200 resolved_row.response.id=F_SYauTtENKK6tkPm_Ke4Qo prompt="Say hi in three words"
--- chat-stream: lookups by x-litellm-call-id 4e917876-1597-4e26-b5f6-76f8ccbef0dd
[master] GET /spend/logs?request_id=4e917876-1597-4e26-b5f6-76f8ccbef0dd -> found at t=0s: [{"request_id":"GvSYaur6Eef26tkP-qH-wAY","litellm_call_id":"4e917876-1597-4e26-b5f6-76f8ccbef0dd","user":"default_user_id","call_type":"acompletion","model":"vertex_ai/gemini-3.8-flash","spend":0.0005759999999999999}]
[master] GET /spend/logs/ui?request_id=4e917876-1597-4e26-b5f6-76f8ccbef0dd -> HTTP 200 total=1 rows=[{"request_id":"GvSYaur6Eef26tkP-qH-wAY","litellm_call_id":"4e917876-1597-4e26-b5f6-76f8ccbef0dd","user":"default_user_id","call_type":"acompletion"}]
[master] GET /spend/logs/ui/4e917876-1597-4e26-b5f6-76f8ccbef0dd -> HTTP 200 resolved_row.response.id=GvSYaur6Eef26tkP-qH-wAY prompt="Count to three"
--- messages: lookups by x-litellm-call-id 0da707e2-e54a-4543-a013-5a8c0aea0100
[master] GET /spend/logs?request_id=0da707e2-e54a-4543-a013-5a8c0aea0100 -> found at t=0s: [{"request_id":"HPSYapOqOrSAodAP2PKM-Q0","litellm_call_id":"0da707e2-e54a-4543-a013-5a8c0aea0100","user":"default_user_id","call_type":"anthropic_messages","model":"vertex_ai/gemini-3.8-flash","spend":0.0002235}]
[master] GET /spend/logs/ui?request_id=0da707e2-e54a-4543-a013-5a8c0aea0100 -> HTTP 200 total=1 rows=[{"request_id":"HPSYapOqOrSAodAP2PKM-Q0","litellm_call_id":"0da707e2-e54a-4543-a013-5a8c0aea0100","user":"default_user_id","call_type":"anthropic_messages"}]
[master] GET /spend/logs/ui/0da707e2-e54a-4543-a013-5a8c0aea0100 -> HTTP 200 resolved_row.response.id=HPSYapOqOrSAodAP2PKM-Q0 prompt="Name one color"
--- responses: lookups by x-litellm-call-id dd28a280-1d32-403b-9bac-ca2482cadfe0
[master] GET /spend/logs?request_id=dd28a280-1d32-403b-9bac-ca2482cadfe0 -> found at t=0s: [{"request_id":"H_SYaoG4D6aJ6tkPwMDE4Ac","litellm_call_id":"dd28a280-1d32-403b-9bac-ca2482cadfe0","user":"default_user_id","call_type":"aresponses","model":"vertex_ai/gemini-3.8-flash","spend":0.0003285}]
[master] GET /spend/logs/ui?request_id=dd28a280-1d32-403b-9bac-ca2482cadfe0 -> HTTP 200 total=1 rows=[{"request_id":"H_SYaoG4D6aJ6tkPwMDE4Ac","litellm_call_id":"dd28a280-1d32-403b-9bac-ca2482cadfe0","user":"default_user_id","call_type":"aresponses"}]
[master] GET /spend/logs/ui/dd28a280-1d32-403b-9bac-ca2482cadfe0 -> HTTP 200 resolved_row.response.id=H_SYaoG4D6aJ6tkPwMDE4Ac prompt={}
--- chat: lookup by the provider response id F_SYauTtENKK6tkPm_Ke4Qo still works
[master] GET /spend/logs?request_id=F_SYauTtENKK6tkPm_Ke4Qo -> found at t=0s: [{"request_id":"F_SYauTtENKK6tkPm_Ke4Qo","litellm_call_id":"cc861aa5-2ab8-4a20-a345-acd5adbc2347","user":"default_user_id","call_type":"acompletion","model":"vertex_ai/gemini-3.8-flash","spend":0.001005}]

Tenant safety (internal users, client-settable call id)

The attacker's spoofed call id never surfaces the victim's row to the attacker, the victim keeps resolving their own row, a bystander gets 403, the admin detail view resolves the exact request_id match first, and an id-filtered list with page_size=1 (the fetch the logs page deep link makes) returns the exact request_id row rather than the newer collided one, on both the plain list and the session-grouped list the logs page defaults to:

1. POST /user/new x3 (internal_user, auto_create_key): victim=c58af8cb-63f8-44a9-85e4-0a8e1887741c attacker=bae5d62d-80fd-4d27-be65-fb316ddc5725 bystander=1731420c-1b25-45fe-9a98-9f0bd80b346b
[victim-chat] POST /v1/chat/completions -> HTTP 200 x-litellm-call-id=1ec1731b-e6c1-44a5-8dcf-7889b9f76071 body_id=K_SYauy8NfWe6tkPrLu-2Q0
[victim] GET /spend/logs?request_id=1ec1731b-e6c1-44a5-8dcf-7889b9f76071 -> found at t=5s: [{"request_id":"K_SYauy8NfWe6tkPrLu-2Q0","litellm_call_id":"1ec1731b-e6c1-44a5-8dcf-7889b9f76071","user":"c58af8cb-63f8-44a9-85e4-0a8e1887741c","call_type":"acompletion","model":"vertex_ai/gemini-3.8-flash","spend":0.00033375}]
2. victim's stored request_id (provider id) = K_SYauy8NfWe6tkPrLu-2Q0
[attacker-chat] POST /v1/chat/completions -> HTTP 200 x-litellm-call-id=K_SYauy8NfWe6tkPrLu-2Q0 body_id=NPSYaoebA6aJ6tkPwMDE4Ac
3. attacker sent its own request with header x-litellm-call-id: K_SYauy8NfWe6tkPrLu-2Q0 (echoed call id above)
4. attacker looks the collided id up:
[attacker] GET /spend/logs?request_id=K_SYauy8NfWe6tkPrLu-2Q0 -> found at t=0s: [{"request_id":"NPSYaoebA6aJ6tkPwMDE4Ac","litellm_call_id":"K_SYauy8NfWe6tkPrLu-2Q0","user":"bae5d62d-80fd-4d27-be65-fb316ddc5725","call_type":"acompletion","model":"vertex_ai/gemini-3.8-flash","spend":0.00034875}]
[attacker] GET /spend/logs/ui?request_id=K_SYauy8NfWe6tkPrLu-2Q0 -> HTTP 200 total=1 rows=[{"request_id":"NPSYaoebA6aJ6tkPwMDE4Ac","litellm_call_id":"K_SYauy8NfWe6tkPrLu-2Q0","user":"bae5d62d-80fd-4d27-be65-fb316ddc5725","call_type":"acompletion"}]
   attacker's /spend/logs/ui response never mentions the victim's user id c58af8cb-63f8-44a9-85e4-0a8e1887741c
5. victim looks the same id up (must still resolve their own row, no 403):
[victim] GET /spend/logs/ui?request_id=K_SYauy8NfWe6tkPrLu-2Q0 -> HTTP 200 total=1 rows=[{"request_id":"K_SYauy8NfWe6tkPrLu-2Q0","litellm_call_id":"1ec1731b-e6c1-44a5-8dcf-7889b9f76071","user":"c58af8cb-63f8-44a9-85e4-0a8e1887741c","call_type":"acompletion"}]
   victim's /spend/logs/ui response never mentions the attacker's user id bae5d62d-80fd-4d27-be65-fb316ddc5725
6. bystander (owns nothing matching) looks it up:
[bystander] GET /spend/logs/ui?request_id=K_SYauy8NfWe6tkPrLu-2Q0 -> HTTP 403 {"error":{"message":"{'error': 'Not authorized to view spend log for request_id=K_SYauy8NfWe6tkPrLu-2Q0'}","type":"internal_server_error","param":"None","code":"403"}}
7. master sees the collision and the detail view resolves the exact request_id match first:
[master] GET /spend/logs/ui?request_id=K_SYauy8NfWe6tkPrLu-2Q0 -> HTTP 200 total=2 rows=[{"request_id":"K_SYauy8NfWe6tkPrLu-2Q0","litellm_call_id":"1ec1731b-e6c1-44a5-8dcf-7889b9f76071","user":"c58af8cb-63f8-44a9-85e4-0a8e1887741c","call_type":"acompletion"},{"request_id":"NPSYaoebA6aJ6tkPwMDE4Ac","litellm_call_id":"K_SYauy8NfWe6tkPrLu-2Q0","user":"bae5d62d-80fd-4d27-be65-fb316ddc5725","call_type":"acompletion"}]
[master] GET /spend/logs/ui/K_SYauy8NfWe6tkPrLu-2Q0 -> HTTP 200 resolved_row.response.id=K_SYauy8NfWe6tkPrLu-2Q0 prompt="victim prompt: say ok"
7b. master with page_size=1 (the fetch the logs page deep link makes) gets the exact request_id row, not the newer collided row:
[master] GET /spend/logs/ui?request_id=K_SYauy8NfWe6tkPrLu-2Q0&page_size=1 -> HTTP 200 total=2 rows=[{"request_id":"K_SYauy8NfWe6tkPrLu-2Q0","litellm_call_id":"1ec1731b-e6c1-44a5-8dcf-7889b9f76071","user":"c58af8cb-63f8-44a9-85e4-0a8e1887741c","call_type":"acompletion"}]
7c. attacker with page_size=1 still only sees their own row:
[attacker] GET /spend/logs/ui?request_id=K_SYauy8NfWe6tkPrLu-2Q0&page_size=1 -> HTTP 200 total=1 rows=[{"request_id":"NPSYaoebA6aJ6tkPwMDE4Ac","litellm_call_id":"K_SYauy8NfWe6tkPrLu-2Q0","user":"bae5d62d-80fd-4d27-be65-fb316ddc5725","call_type":"acompletion"}]
7d. master with page_size=1 on the session-grouped list (the logs page default) also gets the exact request_id row first:
[master] GET /spend/logs/ui?request_id=K_SYauy8NfWe6tkPrLu-2Q0&page_size=1&group_by_session=true -> HTTP 200 total=2 rows=[{"request_id":"K_SYauy8NfWe6tkPrLu-2Q0","litellm_call_id":"1ec1731b-e6c1-44a5-8dcf-7889b9f76071","user":"c58af8cb-63f8-44a9-85e4-0a8e1887741c","call_type":"acompletion"}]
8. attacker's own clean call id (a second request with no x-litellm-call-id header) keeps working:
[attacker-chat-clean] POST /v1/chat/completions -> HTTP 200 x-litellm-call-id=3bf99f9e-0cc9-44fe-aa47-4be69261b45d body_id=QvSYatHKIqCeq8YP-OjW8Ag
[attacker] GET /spend/logs?request_id=3bf99f9e-0cc9-44fe-aa47-4be69261b45d -> found at t=5s: [{"request_id":"QvSYatHKIqCeq8YP-OjW8Ag","litellm_call_id":"3bf99f9e-0cc9-44fe-aa47-4be69261b45d","user":"bae5d62d-80fd-4d27-be65-fb316ddc5725","call_type":"acompletion","model":"vertex_ai/gemini-3.8-flash","spend":0.0006119999999999999}]
[attacker] GET /spend/logs/ui?request_id=3bf99f9e-0cc9-44fe-aa47-4be69261b45d -> HTTP 200 total=1 rows=[{"request_id":"QvSYatHKIqCeq8YP-OjW8Ag","litellm_call_id":"3bf99f9e-0cc9-44fe-aa47-4be69261b45d","user":"bae5d62d-80fd-4d27-be65-fb316ddc5725","call_type":"acompletion"}]
9. internal users on the detail route (RBAC predating this PR):
[victim] GET /spend/logs/ui/K_SYauy8NfWe6tkPrLu-2Q0 -> HTTP 401 {"error":{"message":"Authentication Error, Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/spend/logs/ui/K_SYauy8NfWe6tkPrLu-2Q0. Your role=internal_user

Bounded call id (Postgres btree entry limit)

A 3000-character header no longer poisons the write: the proxy replaces it with a generated id, echoes that id, and the row is stored and found under it (at the merge base the same request left a Postgres 54000 index row size 3016 exceeds btree version 4 maximum 2704 in the proxy log and no row):

--- 3000 random hex chars as x-litellm-call-id on /v1/chat/completions
POST /v1/chat/completions x-litellm-call-id=<3000 random hex chars> -> HTTP 200 provider id=mfSYaqTaBOPHodAPweXYoAQ
POST /v1/chat/completions x-litellm-call-id=ctrl2-429fff53-b422-4e0a-b1ae-82304568e67d -> HTTP 200 provider id=mvSYapzZKY-E6tkP8_HooA0
[big-provider-id] GET /spend/logs?request_id=mfSYaqTaBOPHodAPweXYoAQ -> found at t=6s: [{"request_id": "mfSYaqTaBOPHodAPweXYoAQ", "litellm_call_id": "3405a4ab-0eed-4da5-8bd0-a1917ceb7bd3", "call_type": "acompletion", "model": "vertex_ai/gemini-3.8-flash", "spend": 5.325e-05, "status": "success"}]
[ctrl] GET /spend/logs?request_id=ctrl2-429fff53-b422-4e0a-b1ae-82304568e67d -> found at t=0s: [{"request_id": "mvSYapzZKY-E6tkP8_HooA0", "litellm_call_id": "ctrl2-429fff53-b422-4e0a-b1ae-82304568e6", "call_type": "acompletion", "model": "vertex_ai/gemini-3.8-flash", "spend": 5.325e-05, "status": "success"}]
--- proxy log lines about rejected rows

Logs page deep link

Dashboard dev server from this branch against the after proxy: http://localhost:<ui port>/logs?log_id=7d9a2d5d-41e1-4339-8272-b9067fb587d4 opens the drawer "Request IYeYatSdKoTcodAP_p2BwAI details" (gemini-3.8-flash, $0.001208, 326 tokens) while the list behind it still shows "Showing 1-50 of 93" without that row, i.e. the drawer came from the id fetch, not from the loaded page. Screenshot: lit6302-after-ui-deeplink-callid-drawer.png. RequestLogsPanel.test.tsx covers both the loaded-page and the fetched-by-id paths and fails with the panel change reverted.

To reproduce by hand: run the proxy from this branch, open /ui/logs, send any request, copy its x-litellm-call-id response header, then open /ui/logs?log_id=<that id>

Notes

  • /spend/logs/ui/{request_id} rejects internal users with 401 before the handler runs, on both legs: the route is not in internal_user_routes, RBAC that predates this PR; the handler's ownership check guards whichever non-admin principals can reach the route
  • /spend/logs/ui date filters are UTC; local-time windows silently return empty
  • The messages column stores {} for chat rows on both legs (only _arealtime rows keep messages there; prompts live in proxy_server_request), unrelated to this PR
  • A failure spend row already stores request_id = litellm_call_id, so an HTTP 200 on every leg is what proves the success-row path
  • This branch merged staging's fix(ui): paginate request logs by session groups server-side #39257 (server-side session-grouped pagination) after it conflicted on the page query; the merge keeps that query shape and adds the new column plus the exact-first ordering to both page variants
  • No cold-storage logger was configured in this run, so /spend/logs/ui/{request_id} resolves from the spend row on every leg; the custom-logger path is covered by test_ui_view_request_response_custom_logger_is_keyed_by_callers_own_request_id and listed under Not verified below

CircleCI at 55853c1

86 checks green and 3 red, and all three reds are the GitHub Actions Image Scan jobs (runtime-image, ui-image, image-scan). They build the Docker image off the PR merge ref and fail in npm run build with Cannot find module '../../../../litellm/proxy/public_endpoints/autorouter_presets.json': staging's #39412 moved that JSON out of ui/ while a dashboard test mock still imported it by a relative path the ui-builder stage never copies, so every PR merge ref since #39412 landed fails the same way. Nothing in this PR touches that path, they are not required checks, and staging's #39478 has since fixed the import for everyone, so these reds only survive because the run predates that merge. The proxy-endpoints red on the previous tip was staging's own crowdstrike cadence expectation, fixed by #39467 and green here since this branch merged staging in

Type

🐛 Bug Fix

Caveats (if any)

Medium

  • Rows written before this PR stay findable only by provider id
    • No backfill is possible: the call id of an old request was never persisted anywhere, so the value does not exist to recover, and the migration stays schema-only per the repo's no-data-rewrite rule

Low

  • A non-admin id lookup that matches only other tenants' rows answers 403 instead of an empty list
    • Rows the caller may view keep resolving through a collision, the detail view resolves the caller's own row before any cold-storage logger and prefers the exact request_id match, and id-filtered lists put the exact request_id row first
  • A non-admin details lookup for an id with no spend-log row answers 403 and never asks the cold-storage loggers
  • A client-supplied x-litellm-call-id longer than 256 characters or empty is replaced by a generated id
    • Before this PR any length was accepted and echoed; the response header now carries the stored id, so clients that read it back keep working
  • The spend-logs index builds with CREATE INDEX CONCURRENTLY in its own single-statement migration
    • Writes stay unblocked while it builds, but an interrupted build can leave an INVALID index to drop and recreate, same as the existing health-check index migration
  • Cache-hit rows become call-id findable too
    • Their request_id carries a _cache_hit<ts> suffix; the new column keeps the plain call id
  • /v1/responses' client-visible resp_... id still never resolves
    • Only the call-id lookup works for that endpoint; unchanged from before this PR
  • The raw SQL lookups name the new column unconditionally
    • A proxy running this code against a database whose migrations did not apply fails those lookups until they do; migrations run at boot, so this only shows with migrations disabled

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
  • Passes /live-pr-risk, with the merge-ref legs re-run at 55853c1

/live-pr-risk report (b69351e, merge-ref legs re-run at 55853c1)

The graph walk and the base-vs-head rig ran at b69351e. The only commit since is the staging merge, and git diff <merge-base>...<tip> at 55853c1 is byte-identical to the same diff at b69351e apart from blob hashes and one hunk offset, so every finding below still describes what ships. The merge-ref legs (all four request shapes plus the tenant-safety and bounded-call-id checks) were re-run on the merged tree at 55853c1 and are the output quoted above. The later staging merge at 8608a03 resolved one conflict in the spend-log owner lookup by keeping the base's own missing-row 403 (#34099), so it adds no PR-side behavior; the three touched test modules were re-run on the merged tree (784 passed)

Verdict: no breaking dependent found; two dashboard consumers and one CircleCI fixture depended on the old single-id contract and are fixed in this PR

Breaking

  • None observed. Every provider-id lookup answers the same as on the merge base on all four request shapes (chat, chat stream, messages, responses), and /spend/logs?request_id=<provider id> returns the identical row on both legs

Backward incompatible

  • SpendLogsPayload carries a new litellm_call_id key, so every consumer of the raw payload sees one more field: the SPEND_LOGS_URL batch receiver, the GCS Pub/Sub exporter (litellm/integrations/gcs_pubsub/pub_sub.py), and the spend update writer. The CircleCI Pub/Sub test compares the exported dict key by key and failed on the extra key; its ignored_keys now lists the new field
  • A non-admin id lookup that matches only other tenants' rows answers 403 where it previously returned an empty list, and since b69351e that row scope covers every non-admin role that reaches the list (org admins and allowed_routes keys included, not only internal users), so a colliding foreign row can neither leak nor turn the caller's own lookup into a 403
  • x-litellm-call-id values over 256 characters or empty are replaced by a generated id; the response header echoes the stored value
  • The staging merge at 8608a03 keeps the base's rule from [Bug]: internal_user role never receives messages/response from /spend/logs/ui, even for own requests, despite store_prompts_in_spend_logs=true #34099 that a non-admin id lookup matching no spend-log row answers 403 before any cold-storage logger is asked, where this PR's earlier tips returned the caller's own payload for a pruned row; that early return reopened the exists-but-not-yours oracle, so the base's rule won

Regression risk

  • The Responses API previous_response_id lookup (litellm/responses/session_handler.py) still matches request_id only, unchanged by this PR and untouched by the new column
  • Cold-storage object keys for GCS spend payloads use the provider id (gcs_bucket.py keys objects by {date}/{provider response id}); the detail route now asks the logger for the stored request_id of the caller's own row instead of the raw lookup id, which is what the key builder always keyed by, so call-id lookups now reach the bucket too; not exercised live (no bucket credentials in this run), reasoned from the key builder and covered by a unit test with a recording logger
  • A proxy running this code against a database whose migrations have not applied fails the raw SQL lookups that name the new column; migrations run at boot before traffic, so this only shows with migrations disabled

Dependency graph

  • /spend/logs, /spend/logs/ui, /spend/logs/ui/{request_id}, /spend/logs/v2: verified live on both legs (feature and tenant scripts above)
  • RequestLogsPanel ?log_id= deep link: was untested and read the loaded page only, fixed here, covered by RequestLogsPanel.test.tsx and verified on the dev dashboard
  • GuardrailsMonitor/LogViewer drawer: was untested and took the first returned row, fixed here, covered by LogViewer.test.tsx
  • tests/logging_callback_tests/test_gcs_pub_sub.py: CircleCI job, failed on the new key, fixed here and re-run locally with the premium check satisfied
  • _create_spend_logs_with_poison_isolation: verified live (oversized header at the merge base rejected the row with Postgres 54000, isolated from the batch; at the tip the id is replaced before the write)
  • session_handler.py previous_response_id matching, cold-storage key builder, autorouter session rollup: unchanged, reasoned only
  • get_request_response_payload implementers (additional_logging_utils.py base, gcs_bucket.py, datadog.py, datadog_metrics.py): the detail handler now passes the stored provider id of the caller's own row; GCS keys by that id, the Datadog ones return nothing; reasoned only, unit-tested with a recording logger
  • _spend_log_payload_query consumers (the detail handler and the test fakes): the SELECT gains request_id, a row without it falls back to the lookup id; verified live through /spend/logs/ui/{id}
  • ui_view_spend_logs ORDER BY under an id filter (/spend/logs/ui and /spend/logs/v2 share the handler, the count query is unchanged, the exact-first clause only applies to a string filter and sits on both the plain page and the session-grouped page that fix(ui): paginate request logs by session groups server-side #39257 added on staging): verified live (tenant steps 7b, 7c, and 7d) and unit-tested for both page shapes
  • Late addition in b69351e (bugbot's collision finding), walked on its own: user_scope_applies now covers every non-admin id lookup. The pre-check and the SQL scope share one team rule (_get_permitted_team_ids_for_spend_logs), callers without a user_id already answer 403 at the pre-check on both legs, and the only callers newly scoped are user-table rows whose role is outside the four /user/new and /user/update accept (org_admin, team, customer), which no API writes today. Covered by test_ui_view_spend_logs_id_lookup_scopes_every_non_admin_role, which answers 403 without the fix

Not verified

  • GCS cold storage: the export of the new field and the /spend/logs/ui/{request_id} custom-logger path (no bucket in this run: the only GCP service account available has no storage permission, a bucket list answers 403); the custom-logger path is covered by test_ui_view_request_response_custom_logger_is_keyed_by_callers_own_request_id with a recording logger
  • SPEND_LOGS_URL external receiver (no receiver configured in this run)
  • A live org-admin id lookup: a user-table row set to org_admin by hand (no API writes that role there) is refused at the route check with HTTP 401 on both /spend/logs and /spend/logs/ui (Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/spend/logs/ui. Your role=org_admin) before the handler runs, so the new scope branch was observed only through the unit test

Note

Medium Risk
Changes spend-log query and authorization paths (multi-tenant id collisions, cold-storage detail) plus a production DB migration/index; behavior is heavily tested but mis-scoping could leak or deny log access.

Overview
Adds a persisted litellm_call_id on spend logs (schema + migrations, including a concurrent index) and writes it from request processing. x-litellm-call-id is normalized via resolve_litellm_call_id (empty or >256 chars → generated UUID) before storage and response headers.

Spend and UI lookups that used request_id only now match request_id OR litellm_call_id (API /spend/logs, UI list, detail, ?log_id=). Lists prefer the row whose request_id equals the lookup id when both collide.

Because the call id is client-set and not tenant-unique, non-admin id lookups keep user/team scoping, use uncapped owner discovery, re-check ownership on fetched rows, resolve the caller’s row before cold-storage/custom loggers (provider request_id as the storage key), and authorize logger payloads by embedded owner metadata.

Dashboard RequestLogsPanel / Guardrails LogViewer resolve drawers by either id and prefer the exact request_id match.

Reviewed by Cursor Bugbot for commit 8608a03. Bugbot is set up for automated code reviews on this repo. Configure here.

…kups

Success spend rows are keyed by the upstream provider response id, so the
x-litellm-call-id response header value never found them. Add a nullable
indexed litellm_call_id column to LiteLLM_SpendLogs, populate it at write
time, and widen every request_id lookup surface (/spend/logs,
/spend/logs/ui, request details, ownership check) to match either id.
@codspeed

codspeed Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_spend_log_request_id_call_id (8608a03) with litellm_internal_staging (9ae727b)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR stores the LiteLLM call ID alongside the provider response ID and supports either identifier across spend-log lookup surfaces

  • Adds the nullable indexed litellm_call_id spend-log field and persists bounded call IDs
  • Updates list and detail lookups with tenant scoping, exact request-ID preference, and cold-storage ownership validation
  • Updates dashboard deep links, guardrail log selection, payload types, exports, and regression tests

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains

Important Files Changed

Filename Overview
litellm/proxy/spend_tracking/spend_management_endpoints.py Adds dual-ID spend-log lookup while binding non-admin authorization to the exact database or cold-storage payload returned
litellm/proxy/spend_tracking/spend_tracking_utils.py Persists the resolved LiteLLM call ID in spend-log payloads
litellm/proxy/common_request_processing.py Bounds client-provided call IDs and generates replacements for empty or oversized values
litellm-proxy-extras/litellm_proxy_extras/migrations/20260831120000_add_litellm_call_id_spend_logs/migration.sql Adds the nullable call-ID column without rewriting historical spend rows
litellm-proxy-extras/litellm_proxy_extras/migrations/20260831120001_spend_logs_litellm_call_id_index/migration.sql Adds the call-ID index concurrently in a dedicated migration
ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx Resolves call-ID deep links by fetching and opening the matching request row
ui/litellm-dashboard/src/components/GuardrailsMonitor/LogViewer.tsx Selects the exact request-ID row when lookup results contain identifier collisions

Reviews (16): Last reviewed commit: "Merge origin/litellm_internal_staging in..." | Re-trigger Greptile

Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py Outdated
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.51852% with 7 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...proxy/spend_tracking/spend_management_endpoints.py 93.06% 7 Missing ⚠️

📢 Thoughts on this report? Let us know!

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Lookup auth checks only one row
    • _assert_user_can_view_request_id now uses find_many and iterates every row the request_id/litellm_call_id OR clause resolves to, so a client-supplied litellm_call_id that collides with another tenant's id is refused (403) before any list or detail query runs.

Create PR

Or push these changes by commenting:

@cursor push 1fbd2dbb69
Preview (1fbd2dbb69)
diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py
--- a/litellm/proxy/spend_tracking/spend_management_endpoints.py
+++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py
@@ -243,9 +243,14 @@
     return (request_id_clause, call_id_clause)
 
 
-async def _find_spend_log_row(prisma_client: PrismaClient, request_id: str) -> _SpendLogOwnershipRow | None:
-    """Read the single spend log row identified by ``request_id`` or ``litellm_call_id``."""
-    return await _spend_logs_table(prisma_client).find_first(
+async def _find_spend_log_rows(prisma_client: PrismaClient, request_id: str) -> Sequence[_SpendLogOwnershipRow]:
+    """Read every spend log row matching ``request_id`` or ``litellm_call_id``.
+
+    ``litellm_call_id`` is client-supplied (``x-litellm-call-id``) and not unique,
+    so a single id can address more than one row across tenants. Callers that
+    need to authorize the id must inspect every matching row, not just one.
+    """
+    return await _spend_logs_table(prisma_client).find_many(
         where={"OR": _request_id_or_call_id_clause(request_id)},
         include=None,
     )
@@ -4301,33 +4306,31 @@
     request_id: str,
 ) -> None:
     """
-    Verify the requesting non-admin user is allowed to view this spend-log row.
-    Allowed when the log belongs to the user directly, or to one of their
-    permitted teams (admin or ``/spend/logs`` permission).
+    Verify the requesting non-admin user is allowed to view every spend log row
+    the ``request_id`` lookup can resolve to. Allowed per row when the log
+    belongs to the user directly, or to one of their permitted teams (admin or
+    ``/spend/logs`` permission). Because ``litellm_call_id`` is client-supplied
+    and non-unique, one id can address rows across tenants, so authorization
+    must hold for every matching row: any unowned match denies the request.
     Raises HTTP 403 if not.
     """
-    row: Final = await _find_spend_log_row(prisma_client, request_id)
-    if row is None:
-        return
-
-    if row.user is not None and row.user == user_api_key_dict.user_id:
-        return
-
-    if row.team_id:
-        can_view: Final = await _can_team_member_view_log(
+    rows: Final = await _find_spend_log_rows(prisma_client, request_id)
+    caller_user_id: Final = user_api_key_dict.user_id
+    for row in rows:
+        if caller_user_id is not None and row.user == caller_user_id:
+            continue
+        if row.team_id and await _can_team_member_view_log(
             prisma_client=prisma_client,
             user_api_key_dict=user_api_key_dict,
             team_id=row.team_id,
+        ):
+            continue
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail={"error": f"Not authorized to view spend log for request_id={request_id}"},
         )
-        if can_view:
-            return
 
-    raise HTTPException(
-        status_code=status.HTTP_403_FORBIDDEN,
-        detail={"error": f"Not authorized to view spend log for request_id={request_id}"},
-    )
 
-
 async def _get_permitted_team_ids_for_spend_logs(
     prisma_client: PrismaClient,
     user_api_key_dict: UserAPIKeyAuth,

diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
--- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
+++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
@@ -413,8 +413,8 @@
         team_id = None
 
     class MockSpendLogs:
-        async def find_first(self, where=None, include=None):
-            return MockRow()
+        async def find_many(self, where=None, include=None):
+            return [MockRow()]
 
     class MockDB:
         def __init__(self):
@@ -432,6 +432,44 @@
     assert exc_info.value.status_code == 403
 
 
+@pytest.mark.asyncio
+async def test_assert_user_can_view_request_id_denies_cross_tenant_call_id_collision():
+    """
+    Regression: ``litellm_call_id`` is client-supplied (``x-litellm-call-id``) and
+    not unique, so a caller can seed their own row with a ``litellm_call_id``
+    that collides with another tenant's ``request_id``. The auth check must
+    inspect every matching row rather than just the first one, otherwise it
+    would pass on the caller's owned row and the follow-up list/detail query
+    could return the unowned sibling.
+    """
+
+    class Row:
+        def __init__(self, user, team_id=None):
+            self.user = user
+            self.team_id = team_id
+
+    class MockSpendLogs:
+        async def find_many(self, where=None, include=None):
+            return [Row("caller_user"), Row("victim_user")]
+
+    class MockDB:
+        def __init__(self):
+            self.litellm_spendlogs = MockSpendLogs()
+
+    class MockPrisma:
+        def __init__(self):
+            self.db = MockDB()
+
+    auth = UserAPIKeyAuth(
+        user_role=LitellmUserRoles.INTERNAL_USER, user_id="caller_user"
+    )
+    with pytest.raises(HTTPException) as exc_info:
+        await spend_management_endpoints._assert_user_can_view_request_id(
+            MockPrisma(), auth, "colliding-id"
+        )
+    assert exc_info.value.status_code == 403
+
+
 def test_ui_view_request_response_forbids_non_admin_without_db(client, monkeypatch):
     """
     Without prisma, non-admins cannot be authorized to read request/response
@@ -2334,8 +2372,8 @@
         team_id = None
 
     class _SpendLogs:
-        async def find_first(self, where=None, include=None):
-            return _ForeignRow()
+        async def find_many(self, where=None, include=None):
+            return [_ForeignRow()]
 
     class _DB:
         def __init__(self):
@@ -2400,10 +2438,10 @@
         user = "user_1"
         team_id = "team1"
 
-    async def _find_first(where=None, include=None):
-        return _OwnedRow()
+    async def _find_many(where=None, include=None):
+        return [_OwnedRow()]
 
-    mock_prisma.db.find_first = _find_first
+    mock_prisma.db.find_many = _find_many
     monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
 
     # A 5-day window that EXCLUDES the 90-day-old log, as the dashboard sends.

You can send follow-ups to the cloud agent here.

Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py Outdated
litellm_call_id is populated from the client-settable x-litellm-call-id
header, so a request_id lookup can match more than one row across
tenants. Authorizing on a single arbitrary match let an attacker reuse
a victim's request_id as their own call id and read the victim's spend
log row. Widen the ownership check to require every matching row to
belong to the caller, failing closed on any foreign match.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🚅 Hi, thanks for the PR! I'm Agent Shin, the automated triage bot for this repository. What's this and why am I getting it?

I read the description against our contribution rubric. Here's how it lined up:

What you got right:

  • ✅ Linked a related GitHub issue
  • ✅ Clear problem description
  • ✅ Expected vs. actual behavior
  • ✅ End-to-end QA proof

What's still missing:

  • A Greptile confidence score of at least 4/5: the latest review scores this 3/5 and flags spend-log lookup authorization (spend_management_endpoints.py) as unsafe to merge

The description and the live-proxy before/after QA proof are both strong, so the only gap is the quality gate: Greptile's most recent review is 3/5 with a security finding. Comment @greptileai once the owner-scoped match fix is pushed so a fresh score can clear this.

If the description isn't updated in the next 2 hours, I'll auto-close this PR. That's not us saying we don't care about the change; we want the open-PR list to mirror what a maintainer can act on right now, so contributors don't get lost in a backlog. A closed PR is a soft "park this for later," not a rejection. Take your time; everything below still works after the close.

During the grace period: just update the PR description with the missing pieces. No need to ping me; I'll re-check on the next sweep and skip the auto-close if it now passes. See what counts as QA proof for the full rubric (a linked issue alone isn't enough; it covers context, not proof).

If the PR does get auto-closed in 2 hours, you still have easy recovery paths:

  • Comment @agent-shin reconsider after updating the description. I'll re-evaluate and reopen the PR if it now passes.
  • Comment @greptileai to request a fresh Greptile review; that still works even after the PR is closed, and a stronger score is one of the signals that lifts the PR back into the queue. So a low Greptile score isn't a blocker either.

Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.

(I'm an LLM, so I'm not infallible. If you think I got this wrong, ping a maintainer; they'll override me.)

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py
@veria-ai

veria-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py
The custom-logger detail branch reads the payload from cold storage, which is
written independently of the spend-log table and can outlive its row. The DB
owner pre-check then has nothing to verify for an id lookup that matches no row,
so a foreign tenant's stored payload could be returned. Authorize the returned
payload against the owner recorded inside it (metadata user/team id), failing
closed when none is recorded. Also fold the three identical 403 raises into one
helper.
@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Deep link opens colliding call-id row
    • The deep-link drawer now prefers the exact request_id match over any colliding litellm_call_id, both in the displayLog selection over filteredLogs and in the by-id fetch (which now pulls enough rows to actually contain the exact-match row).
  • ✅ Fixed: Cold-storage hit blocks owned row
    • The cold-storage payload ownership check is now a boolean that lets the endpoint skip a foreign-owned payload and fall through to the scoped DB fallback, so a colliding cold-storage entry no longer 403s the caller's own matching row.

Create PR

Or push these changes by commenting:

@cursor push 20d4dcda13
Preview (20d4dcda13)
diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py
--- a/litellm/proxy/spend_tracking/spend_management_endpoints.py
+++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py
@@ -2901,15 +2901,16 @@
             start_time_utc=start_date_obj,
             end_time_utc=end_date_obj,
         )
-        if payload is not None:
-            if not caller_is_admin and prisma_client is not None:
-                await _assert_user_owns_cold_storage_payload(
-                    prisma_client=prisma_client,
-                    user_api_key_dict=user_api_key_dict,
-                    payload=cast(Mapping[str, object], payload),  # cast-ok: custom-logger payload is untyped
-                    request_id=request_id,
-                )
-            return payload
+        if payload is None:
+            continue
+        if not caller_is_admin and prisma_client is not None:
+            if not await _user_can_view_cold_storage_payload(
+                prisma_client=prisma_client,
+                user_api_key_dict=user_api_key_dict,
+                payload=cast(Mapping[str, object], payload),  # cast-ok: custom-logger payload is untyped
+            ):
+                continue
+        return payload
 
     # Fallback: the list endpoint omits the heavy columns for performance, so
     # serve them here. When prompts were offloaded to cold storage the DB holds
@@ -4462,23 +4463,24 @@
     )
 
 
-async def _assert_user_owns_cold_storage_payload(
+async def _user_can_view_cold_storage_payload(
     prisma_client: PrismaClient,
     user_api_key_dict: UserAPIKeyAuth,
     payload: Mapping[str, object],
-    request_id: str,
-) -> None:
+) -> bool:
     """
     Authorize a cold-storage payload against the owner recorded inside it.
     The custom logger reads the payload straight from cold storage, written
-    independently of the spend-log table and able to outlive its row, so a
-    request_id lookup could otherwise hand back another tenant's stored payload
-    when no row exists for the pre-check to catch. Verifying the payload's own
-    owner closes that gap, and a payload that records no owner fails closed.
+    independently of the spend-log table and able to outlive its row, and cold
+    storage is keyed by provider ``request_id``, so a lookup id that also exists
+    as another tenant's provider id would otherwise hand back that tenant's
+    stored payload. Verifying the payload's own owner closes that gap; a payload
+    that records no owner fails closed. Callers skip a foreign-owned payload and
+    fall through to the scoped DB query, so the caller's own matching row is
+    still served when a colliding cold-storage hit is not theirs to view.
     """
     owner_user, owner_team_id = _cold_storage_payload_owner(payload)
-    if not await _user_can_view_spend_log_owner(prisma_client, user_api_key_dict, owner_user, owner_team_id):
-        raise _spend_log_forbidden(request_id)
+    return await _user_can_view_spend_log_owner(prisma_client, user_api_key_dict, owner_user, owner_team_id)
 
 
 async def _get_permitted_team_ids_for_spend_logs(

diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
--- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
+++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py
@@ -2709,12 +2709,12 @@
 
 
 @pytest.mark.asyncio
-async def test_ui_view_request_response_custom_logger_denies_foreign_payload_owner(client, monkeypatch):
-    """The custom-logger payload comes straight from cold storage, written independently
-    of the spend-log table and able to outlive its row. When an id lookup matches no row,
-    the DB owner pre-check has nothing to verify, so the payload is authorized against the
-    owner recorded inside it. A foreign tenant's stored payload is denied even though no
-    spend-log row exists for the pre-check to catch."""
+async def test_ui_view_request_response_custom_logger_skips_foreign_payload_owner(client, monkeypatch):
+    """The custom-logger payload comes straight from cold storage, keyed by provider
+    request_id, so a lookup id that also exists as another tenant's provider id could
+    otherwise hand back that tenant's stored payload. A foreign-owned payload is skipped
+    so the caller's own matching row is still served by the DB fallback; when no such
+    row exists, the endpoint returns null instead of leaking the foreign payload."""
 
     class MockDB:
         async def query_raw(self, sql_query, *params):
@@ -2747,13 +2747,72 @@
             params={"start_date": "2026-01-01 00:00:00"},
             headers={"Authorization": "Bearer sk-test"},
         )
-        assert response.status_code == 403
+        assert response.status_code == 200
         assert "victim prompt" not in response.text
+        assert response.json() is None
     finally:
         app.dependency_overrides.pop(ps.user_api_key_auth, None)
 
 
 @pytest.mark.asyncio
+async def test_ui_view_request_response_custom_logger_falls_through_to_owned_db_row(client, monkeypatch):
+    """A colliding foreign cold-storage payload must not lock the caller out of their
+    own matching DB row. Skipping the foreign payload lets the scoped DB query return
+    the caller's own row, which the pre-check already authorized on the shared id."""
+
+    class MockDB:
+        async def query_raw(self, sql_query, *params):
+            if 'SELECT DISTINCT "user", team_id' in sql_query:
+                return [
+                    {"user": "user_1", "team_id": None},
+                    {"user": "victim_user", "team_id": None},
+                ]
+            return [
+                {
+                    "messages": [{"role": "user", "content": "my own prompt"}],
+                    "response": {"id": "r"},
+                    "proxy_server_request": None,
+                    "metadata": None,
+                    "user": "user_1",
+                    "team_id": None,
+                }
+            ]
+
+    class MockPrisma:
+        def __init__(self):
+            self.db = MockDB()
+
+    class ColdStorageLogger:
+        async def get_request_response_payload(self, request_id, start_time_utc, end_time_utc):
+            return {
+                "messages": [{"role": "user", "content": "victim prompt"}],
+                "response": {"id": "r"},
+                "metadata": {"user_api_key_user_id": "victim_user", "user_api_key_team_id": None},
+            }
+
+    monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrisma())
+    monkeypatch.setattr(
+        litellm.logging_callback_manager,
+        "get_active_additional_logging_utils_from_custom_logger",
+        lambda: [ColdStorageLogger()],
+    )
+    app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
+        user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1"
+    )
+    try:
+        response = client.get(
+            "/spend/logs/ui/shared-id",
+            params={"start_date": "2026-01-01 00:00:00"},
+            headers={"Authorization": "Bearer sk-test"},
+        )
+        assert response.status_code == 200, response.text
+        assert "my own prompt" in response.text
+        assert "victim prompt" not in response.text
+    finally:
+        app.dependency_overrides.pop(ps.user_api_key_auth, None)
+
+
+@pytest.mark.asyncio
 async def test_ui_view_request_response_custom_logger_allows_own_payload_without_db_row(client, monkeypatch):
     """The payload-owner authorization must not false-deny a legitimate owner whose
     spend-log row is already gone from the DB. An empty owner lookup with a cold-storage

diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx
@@ -278,9 +278,15 @@
     });
 
     it("fetches the log by request_id and opens the drawer when it is not in the loaded page", async () => {
-      vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params }) =>
+      vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params, page_size }) =>
         params?.request_id === "req-old"
-          ? { data: [logEntry({ request_id: "req-old" })], total: 1, page: 1, page_size: 1, total_pages: 1 }
+          ? {
+              data: [logEntry({ request_id: "req-old" })],
+              total: 1,
+              page: 1,
+              page_size: page_size ?? 1,
+              total_pages: 1,
+            }
           : { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 },
       );
       renderPanel("?log_id=req-old");
@@ -295,9 +301,45 @@
         .mock.calls.find(([options]) => options.params?.request_id === "req-old")?.[0];
       if (!byIdCall) throw new Error("expected a by-id uiSpendLogsCall");
       expect(byIdCall.page).toBe(1);
-      expect(byIdCall.page_size).toBe(1);
+      expect(byIdCall.page_size).toBeGreaterThan(1);
     });
 
+    it("prefers the exact request_id row over a colliding litellm_call_id row on the loaded page", async () => {
+      respondWith([
+        logEntry({ request_id: "attacker-req", litellm_call_id: "victim-req" }),
+        logEntry({ request_id: "victim-req", litellm_call_id: "victim-call-id" }),
+      ]);
+      renderPanel("?log_id=victim-req");
+
+      await waitFor(() => {
+        expect(drawer()).toHaveTextContent("open");
+      });
+      expect(drawer()).toHaveAttribute("data-log-id", "victim-req");
+    });
+
+    it("prefers the exact request_id row over a colliding litellm_call_id row from the by-id fetch", async () => {
+      vi.mocked(uiSpendLogsCall).mockImplementation(async ({ params }) =>
+        params?.request_id === "victim-req"
+          ? {
+              data: [
+                logEntry({ request_id: "attacker-req", litellm_call_id: "victim-req" }),
+                logEntry({ request_id: "victim-req", litellm_call_id: "victim-call-id" }),
+              ],
+              total: 2,
+              page: 1,
+              page_size: 10,
+              total_pages: 1,
+            }
+          : { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 },
+      );
+      renderPanel("?log_id=victim-req");
+
+      await waitFor(() => {
+        expect(drawer()).toHaveTextContent("open");
+      });
+      expect(drawer()).toHaveAttribute("data-log-id", "victim-req");
+    });
+
     it("opens the drawer when ?log_id= is the log's litellm_call_id rather than its request_id", async () => {
       respondWith([logEntry({ request_id: "chatcmpl-provider", litellm_call_id: "call-1" })]);
       renderPanel("?log_id=call-1");

diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx
--- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx
@@ -25,8 +25,11 @@
 import { RequestLogsTable } from "./RequestLogsTable";
 
 const PAGE_SIZE = 50;
+const BY_ID_PAGE_SIZE = 10;
 const DEFAULT_INTERVAL = { value: 24, unit: "hours" };
 const matchesLogId = (log: LogEntry, logId: string) => log.request_id === logId || log.litellm_call_id === logId;
+const findByLogId = (logs: readonly LogEntry[], logId: string): LogEntry | null =>
+  logs.find((log) => log.request_id === logId) ?? logs.find((log) => matchesLogId(log, logId)) ?? null;
 
 interface RequestLogsPanelProps {
   accessToken: string;
@@ -131,12 +134,12 @@
         start_date: window.start_date,
         end_date: window.end_date,
         page: 1,
-        page_size: 1,
+        page_size: BY_ID_PAGE_SIZE,
         params: { request_id: urlLogId },
       });
-      return response.data.find((log) => matchesLogId(log, urlLogId)) ?? null;
+      return findByLogId(response.data, urlLogId);
     },
-    enabled: urlLogId !== null && !(selectedLog !== null && matchesLogId(selectedLog, urlLogId)),
+    enabled: urlLogId !== null && selectedLog?.request_id !== urlLogId,
     staleTime: Infinity,
   };
 
@@ -144,8 +147,8 @@
 
   const displayLog = useMemo<LogEntry | null>(() => {
     if (urlLogId === null) return null;
-    if (selectedLog !== null && matchesLogId(selectedLog, urlLogId)) return selectedLog;
-    return filteredLogs.data.find((log) => matchesLogId(log, urlLogId)) ?? urlLog ?? null;
+    if (selectedLog?.request_id === urlLogId) return selectedLog;
+    return findByLogId(filteredLogs.data, urlLogId) ?? urlLog ?? null;
   }, [urlLogId, selectedLog, filteredLogs.data, urlLog]);
 
   const displaySessionId = useMemo<string | null>(() => {

You can send follow-ups to the cloud agent here.

Comment thread ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx Outdated
Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py
@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.

Stale Bugbot comment from a previous run.

…itellm_spend_log_request_id_call_id

# Conflicts:
#	litellm/proxy/spend_tracking/spend_management_endpoints.py
@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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Non-internal id lookup 403s on collision
    • Extended user_scope_applies to cover every non-admin id lookup (not just INTERNAL_USER roles), so the SQL scopes to rows the caller can view and a foreign litellm_call_id collision can no longer end up in the fetched set that _assert_user_owns_fetched_spend_rows 403s on.

Create PR

Or push these changes by commenting:

@cursor push 72b9b0c543
Preview (72b9b0c543)
diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py
--- a/litellm/proxy/spend_tracking/spend_management_endpoints.py
+++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py
@@ -2493,7 +2493,12 @@
                 request_id=request_id,
             )
         user_scope_applies: Final = (
-            not is_admin_view and team_id is None and _can_user_view_spend_log(user_api_key_dict=user_api_key_dict)
+            not is_admin_view
+            and team_id is None
+            and (
+                is_request_id_lookup
+                or _can_user_view_spend_log(user_api_key_dict=user_api_key_dict)
+            )
         )
         permitted_team_ids: Final = (
             await _get_permitted_team_ids_for_spend_logs_or_empty(

You can send follow-ups to the cloud agent here.

Comment thread litellm/proxy/spend_tracking/spend_management_endpoints.py
An org admin or an allowed_routes key reaches /spend/logs/ui without the
internal-user row scope, so with either-id matching a foreign row carrying
the caller's request_id as its litellm_call_id made the post-fetch owner
check 403 the caller's own lookup. Every non-admin id lookup now applies
the same SQL owner/team scope internal users get
@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.

Stale Bugbot comment from a previous run.

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

Stale Bugbot comment from a previous run.

…id_call_id

Keeps the base's rule that a non-admin id lookup matching no spend-log row answers 403, so the detail route never consults cold storage without an owner row
@mateo-berri mateo-berri added run-ci and removed run-ci labels Sep 13, 2026
@mateo-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri
mateo-berri merged commit a978ad2 into litellm_internal_staging Sep 13, 2026
127 of 135 checks passed
@mateo-berri
mateo-berri deleted the litellm_spend_log_request_id_call_id branch September 13, 2026 04:12

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.

Reviewed by Cursor Bugbot for commit 8608a03. Configure here.

if max_spend is not None:
where_conditions["spend"]["lte"] = max_spend
# A request_id lookup drops the date window, so a non-admin could otherwise
# reach any single row by id; require they own it, mirroring the detail

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.

Search omits call-id matches

Medium Severity

The logs page search box sends search, and _build_spend_log_search_condition still matches only request_id plus a few other columns. Pasting an x-litellm-call-id into that box therefore returns no row for success spend logs, even though the deep-link request_id path now finds them.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8608a03. Configure here.

return rows


async def _find_spend_log_row(prisma_client: PrismaClient, request_id: str) -> _SpendLogOwnershipRow | None:

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.

Summary lookup ignores call id

Medium Severity

GET /spend/logs with dates defaults to summarize=true and _spend_logs_daily_summary_sql still filters only request_id. The same handler’s unsummarized path now matches litellm_call_id, so a call-id lookup that works without dates returns no spend once a date window is supplied.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8608a03. Configure here.

status=_get_status_for_spend_log(
metadata=metadata,
),
litellm_call_id=litellm_call_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.

Spend writes skip call-id cap

Medium Severity

litellm_call_id is now written to an indexed column, but get_logging_payload stores kwargs as-is. Only ProxyBaseLLMRequestProcessing.pre_call runs resolve_litellm_call_id. MCP, A2A, and batch paths that set a long id can still hit the btree row-size failure this PR is meant to stop, so those spend rows are dropped.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8608a03. Configure here.

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.

2 participants