Skip to content

fix(spend): fall back to the call id when a provider returns a null response id - #41026

Open
ChiFungHillmanChan wants to merge 1 commit into
BerriAI:mainfrom
ChiFungHillmanChan:bugfix/null-provider-response-id-drops-spend-logs
Open

fix(spend): fall back to the call id when a provider returns a null response id#41026
ChiFungHillmanChan wants to merge 1 commit into
BerriAI:mainfrom
ChiFungHillmanChan:bugfix/null-provider-response-id-drops-spend-logs

Conversation

@ChiFungHillmanChan

@ChiFungHillmanChan ChiFungHillmanChan commented Sep 14, 2026

Copy link
Copy Markdown

TLDR

Problem this solves:

  • A provider answering with "id": null records the string "None"
  • That string is the spend row's primary key
  • The batch insert skips duplicates, so repeats are dropped
  • Only the first such request is ever recorded, with no error

How it solves it:

  • Fall back to the call id when the provider id is null
  • Takes the issue's second suggestion, at the source
  • A provider id that is actually present still wins

User Flow

Before: an operator serving Gemini through Databricks sees one row in Request Logs no matter how many calls they make, and nothing on the page reports an error

  1. They send POST https://litellm-domain/v1/chat/completions with "model": "databricks-gemini-3-5-flash-lite" and get 200 OK with a normal answer
  2. They send the same request twice more, both 200 OK
  3. They open https://litellm-domain/ui/?page=logs and filter to that model, expecting one row per call
  4. They see a single row, whose Request ID column reads None, and the other calls are absent
  5. Searching Request Logs by the ids of the calls they made finds nothing, so the day's usage cannot be reconciled against the 200s they received

After: every call is its own row

  1. They send POST https://litellm-domain/v1/chat/completions with "model": "databricks-gemini-3-5-flash-lite" and get 200 OK with a normal answer
  2. They send the same request twice more, both 200 OK
  3. They open https://litellm-domain/ui/?page=logs and filter to that model, expecting one row per call
  4. They see three rows, each with its own Request ID, and none reading None
  5. Searching by any one of those ids finds that call, and the day's usage reconciles

Relevant issues

Fixes #39749

Affected release

regression in v1.98.0

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
  • 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

Screenshots / Proof of Fix

A Postgres-backed proxy against a local upstream that answers with an OpenAI-shaped body whose id is null, standing in for the Databricks serving-endpoint behaviour the issue reports. Three identical requests go through a databricks/ deployment and the spend table is read back. Three other provider prefixes are configured against the same upstream as controls: they build a ModelResponse, which generates an id of its own, so the null only survives on the databricks/ path

for n in 1 2 3; do
  curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:4000/v1/chat/completions \
    -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' \
    -d "{\"model\":\"via-databricks\",\"messages\":[{\"role\":\"user\",\"content\":\"$n\"}]}"
done

psql -d litellm_proof -c 'select request_id, model from "LiteLLM_SpendLogs" order by "startTime";'

Before (30f33a9)

  1. Run the commands above. All three requests return 200
  2. The spend table holds one row for the three, and the controls alongside it:
                       request_id                   |                    model
     -----------------------------------------------+---------------------------------------------
      chatcmpl-9e1115cb-e719-4647-a973-bb152c083616 | openai/null-id-upstream
      None                                          | databricks/databricks-gemini-3-5-flash-lite
      66fd479d-b879-4d81-8b83-fba06879aaaf          | openai_like/null-id-upstream
      chatcmpl-fefc9249-49c6-4ed5-b0d8-c704d3f079a6 | custom_openai/null-id-upstream
    
  3. Two of the three databricks requests left no trace, and the row that did land is keyed on the literal string None

After (9881d8b)

  1. Run the commands above. All three requests return 200
  2. All three are recorded:
                   request_id              |                    model
     --------------------------------------+---------------------------------------------
      8771883a-0246-4f66-92a5-b796d4a0556b | databricks/databricks-gemini-3-5-flash-lite
      a5197e80-39bd-4032-ab3f-6910f17a9350 | databricks/databricks-gemini-3-5-flash-lite
      5eb760b9-7b0e-4b6f-8491-4c2ba66c4c33 | databricks/databricks-gemini-3-5-flash-lite
    
  3. No row carries None, and the controls are unchanged

Type

🐛 Bug Fix

Caveats (if any)

Medium

  • This takes the issue's second suggestion rather than its first. Rejecting the literal "None" inside get_spend_logs_id would leave that same string on standard_logging_object["id"], where every other logging integration reads it, and it would match on a magic string rather than on the value being absent

Low

  • The changed line predates the regression. What v1.98.0 changed is that get_spend_logs_id began reading this field ahead of the call id, which is what made the bad value reachable
  • With no call id available either, the payload can still read "None". The proxy always sets one, so the reported flow is covered, and widening that is a separate change
  • Rows already written as "None" are left as they are, since a migration must not rewrite data
  • Only providers that pass the upstream body through are affected; a ModelResponse generates its own id

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

@ChiFungHillmanChan
ChiFungHillmanChan requested a review from a team September 14, 2026 00:05
@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR preserves provider response IDs when present and falls back to the LiteLLM call ID when a provider returns a null or otherwise falsy ID, preventing duplicate spend-log request IDs. It adds focused regression coverage for null and valid provider IDs

Confidence Score: 4/5

The behavior change appears correct, but the explicit repository comment policy must be satisfied before merging

The previous finding was only partly addressed. The docstring was shortened, but lines 3967-3971 still explain straightforward test mechanics and therefore remain outside CLAUDE.md's allowed comment categories

Files Needing Attention: tests/test_litellm/litellm_core_utils/test_litellm_logging.py

Important Files Changed

Filename Overview
litellm/litellm_core_utils/litellm_logging.py Uses the per-call identifier when the provider response does not contain a usable ID
tests/test_litellm/litellm_core_utils/test_litellm_logging.py Covers null-ID fallback and provider-ID precedence, but the previously flagged explanatory docstring remains

Reviews (2): Last reviewed commit: "fix(spend): fall back to the call id whe..." | Re-trigger Greptile

Comment on lines +3967 to +3973
"""A provider answering with a null `id` must not name every one of its rows "None".

`dict.get` returns its default only for an absent key, so a key present and null went
on to `str()` and each such request logged the same literal "None" as its request id.
That id is the spend row's primary key and the writer inserts with skip_duplicates, so
the first row landed and every one after it was dropped with no error raised.
"""

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 Unnecessary test docstring

This six-line docstring explains straightforward mechanics, violating the directive to reserve comments for complex logic. Remove it before merging

Suggested change
"""A provider answering with a null `id` must not name every one of its rows "None".
`dict.get` returns its default only for an absent key, so a key present and null went
on to `str()` and each such request logged the same literal "None" as its request id.
That id is the spend row's primary key and the writer inserts with skip_duplicates, so
the first row landed and every one after it was dropped with no error raised.
"""

Context Used: CLAUDE.md (source)

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!

@codecov

codecov Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing ChiFungHillmanChan:bugfix/null-provider-response-id-drops-spend-logs (9881d8b) with main (30f33a9)

Open in CodSpeed

…esponse id

dict.get returns its default only for an absent key, so a response carrying
"id": null reached str() and logged the literal string "None" as its request
id. That id is the spend row's primary key and the writer inserts with
skip_duplicates, so the first such row landed and every one after it was
dropped without raising.

A provider id that is actually present still wins, unchanged.
@ChiFungHillmanChan
ChiFungHillmanChan force-pushed the bugfix/null-provider-response-id-drops-spend-logs branch from 8cd6fba to 9881d8b Compare September 14, 2026 00:53
@ChiFungHillmanChan

Copy link
Copy Markdown
Author

Trimmed the new test's docstring to match the file's convention and re-ran the QA at the new tip. @greptileai

@ChiFungHillmanChan

Copy link
Copy Markdown
Author

proxy-behavior fails the same MagicMock await in test_auth_object_prefetch on #41024 and #41022, and passes on my #41027. osv-scan is the mlflow CVE in uv.lock.

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

Labels

None yet

Projects

None yet

1 participant