Skip to content

feat(spend-logs): mark the auto-router's own classifier calls - #35300

Merged
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit5014_classifier_call_badge
Jul 31, 2026
Merged

feat(spend-logs): mark the auto-router's own classifier calls#35300
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit5014_classifier_call_badge

Conversation

@tin-berri

@tin-berri tin-berri commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Classifier calls look identical to real user requests in Spend Logs
  • No field on the row says the auto-router made it
  • Classifier rows land in their own session, not the caller's

How it solves it:

  • Declare internal_call_origin on the spend log metadata allowlist
  • Stamp it once, where both classifier paths already converge
  • Forward the parent's session so the row joins its trace

Relevant issues

  • The auto-router's LLM and embedding classifiers now mark their own spend log rows
  • The marker is reserved, so a caller cannot label their own traffic as router overhead
  • Classifier calls join the session of the request that triggered them

Linear ticket

Resolves LIT-5014

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • 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

Live proxy on a throwaway Postgres, one auto-router whose LLM classifier and tiers both point at a real Anthropic model. Upstream was the sandbox gateway (anthropic/us.anthropic.claude-opus-4-8) rather than api.anthropic.com, because the Anthropic and OpenAI keys on this machine are both out of credit; the calls are real and cost real money, and a direct-to-provider re-run is owed

Same three commands on both runs, so the diff is the only discriminator

# 1. auto-routed request; the router runs its LLM classifier to pick a tier
curl -s -X POST $PROXY/v1/chat/completions -H "Authorization: Bearer $KEY" \
  -d '{"model":"smart-router","messages":[{"role":"user","content":"Write a haiku about routing"}],"max_tokens":40,"litellm_session_id":"SESS-CHAT"}'

# 2. ordinary request straight to the same model group, no auto-router
curl -s -X POST $PROXY/v1/chat/completions -H "Authorization: Bearer $KEY" \
  -d '{"model":"gw-opus","messages":[{"role":"user","content":"say hello"}],"max_tokens":10,"litellm_session_id":"SESS-ORDINARY"}'

# 3. caller tries to label its own traffic as router overhead
curl -s -X POST $PROXY/v1/chat/completions -H "Authorization: Bearer $KEY" \
  -H 'x-litellm-spend-logs-metadata: {"note":"kept"}' \
  -d '{"model":"gw-opus","messages":[{"role":"user","content":"say hi"}],"max_tokens":10,"litellm_session_id":"SESS-FORGED","metadata":{"internal_call_origin":"autorouter_classifier"}}'

Before, at fb79a4ee3b

+----------+--------------+-------------+--------------------+----------------------+----------+
|    t     | model_group  |  call_type  |     session_id     | internal_call_origin |  spend   |
+----------+--------------+-------------+--------------------+----------------------+----------+
| 00:59:56 | gw-opus      | acompletion | 06418c33-66f9-4689 |                      | 0.002767 |
| 00:59:58 | smart-router | acompletion | SESSION-BASE-001   |                      | 0.000880 |
| 01:00:05 | gw-opus      | acompletion | SESSION-BASE-ORDIN |                      | 0.000325 |
+----------+--------------+-------------+--------------------+----------------------+----------+

Row 1 is the classifier call and row 3 is a request the user actually sent. They agree on every visible field: same model_group, because the router overwrites it to the classifier's own model group, and same call_type. Nothing distinguishes them. Row 1 also sits in 06418c33-66f9-4689, a session of its own, rather than the SESSION-BASE-001 its parent ran in, so it is absent from the caller's trace. It cost more than the completion it decided

After, at f1481c3b46

+----------+--------------+--------------------+----------------+-----------------------+----------+
|    t     | model_group  |     call_type      |   session_id   | internal_call_origin  |  spend   |
+----------+--------------+--------------------+----------------+-----------------------+----------+
| 01:57:42 | gw-opus      | acompletion        | SESS-CHAT      | autorouter_classifier | 0.002767 |
| 01:57:45 | smart-router | acompletion        | SESS-CHAT      |                       | 0.001018 |
| 01:57:47 | gw-opus      | acompletion        | SESS-ORDINARY  |                       | 0.000325 |
| 01:57:48 | gw-opus      | acompletion        | SESS-FORGED    |                       | 0.000325 |
| 01:57:50 | gw-opus      | acompletion        | SESS-MESSAGES  | autorouter_classifier | 0.002767 |
| 01:57:52 | smart-router | anthropic_messages | SESS-MESSAGES  |                       | 0.000990 |
| 01:57:53 | gw-opus      | acompletion        | SESS-RESPONSES | autorouter_classifier | 0.002767 |
| 01:57:55 | smart-router | aresponses         | SESS-RESPONSES |                       | 0.000935 |
+----------+--------------+--------------------+----------------+-----------------------+----------+

The classifier row is labelled and now shares SESS-CHAT with the request that triggered it. The ordinary request on SESS-ORDINARY stays unlabelled, which is the negative class the whole treatment depends on; without it the marker would only look right. SESS-FORGED is the same request with internal_call_origin supplied by the caller, and it is unlabelled too

All three LLM endpoints are covered: acompletion, anthropic_messages and aresponses each show a labelled classifier row chained to the parent's session. /v1/messages was sent with litellm_metadata seeded so the litellm_metadata bucket arm is genuinely exercised rather than falling through to metadata

The strip is surgical rather than a blanket drop; the caller's own spend-log metadata on that same forged request survived:

   session   | origin |  caller_bucket
-------------+--------+------------------
 SESS-FORGED |        | {"note": "kept"}

Type

🆕 New Feature

Changes

  • internal_call_origin declared on SpendLogsMetadata, the allowlist _get_spend_logs_metadata projects onto
  • Stamped in _classifier_call_metadata, which both the LLM and embedding classifier paths already call, and which feeds the metadata and litellm_metadata buckets alike
  • Added to the untrusted-metadata strip next to routing_decision, so a caller-supplied value is dropped at ingress on every route
  • The classifier sub-call forwards the parent's litellm_session_id / litellm_trace_id, so its row joins the parent's session instead of a fresh one

What does not change: nothing new is billed, no schema migration, no new endpoint or query parameter. The classifier rows already existed and were already billed; they were simply unlabelled and attributed to a session of their own

Things a reviewer will ask about

Why a declared field rather than the existing free-form spend_logs_metadata bucket: that bucket is populated from the caller's x-litellm-spend-logs-metadata header, so a value there is forgeable and would collide with a caller's own key of the same name. A reserved key can be stripped at ingress; a caller-owned bucket cannot

Why not reuse routing_decision: it records which model an auto-router picked for a request. A classifier row did not route anything, and it is absent on ordinary un-routed requests too, so its presence or absence cannot identify a classifier call

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

Note

Low Risk
Changes are limited to metadata stamping, ingress stripping, and session forwarding for internal router sub-calls; billing behavior and schema migrations are unchanged.

Overview
Adds internal_call_origin to spend-log metadata so auto-router LLM and embedding classifier sub-calls are distinguishable from real user traffic in Spend Logs.

The field is allowlisted on SpendLogsMetadata, stamped in _classifier_call_metadata for both classifier paths, and stripped at proxy ingress (alongside routing_decision) so callers cannot forge autorouter_classifier. Classifier acompletion / embedding calls also inherit the parent’s litellm_session_id and litellm_trace_id so those rows appear in the same session/trace as the routed request.

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

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds trusted classifier-call attribution to spend logs.

  • Defines and allowlists the internal_call_origin metadata field.
  • Centralizes the auto-router classifier-origin value beside its Literal type.
  • Strips caller-supplied origin markers while preserving internally stamped values.
  • Propagates parent session and trace identifiers to LLM and embedding classifier sub-calls.
  • Adds coverage for metadata projection, spoof prevention, session chaining, and both classifier paths.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains, and the previously requested classifier-origin constant is now centralized with its type and consumed by the classifier paths.

Important Files Changed

Filename Overview
litellm/constants.py Adds the centralized metadata-key constant used for classifier-call attribution.
litellm/types/utils.py Centralizes the typed auto-router classifier-origin value, fully addressing the prior review thread.
litellm/proxy/_types.py Adds the classifier-origin field to the spend-log metadata allowlist.
litellm/proxy/litellm_pre_call_utils.py Reserves the origin field by stripping caller-supplied values from untrusted metadata.
litellm/proxy/spend_tracking/spend_tracking_utils.py Includes a stable default for the new origin field in generated spend-log metadata.
litellm/router_strategy/complexity_router/complexity_router.py Stamps classifier sub-calls with the centralized origin value and forwards parent session and trace identifiers.
tests/test_litellm/router_strategy/test_complexity_router.py Covers centralized classifier metadata, bucket preservation, sanitization, and parent trace propagation.
tests/test_litellm/proxy/test_litellm_pre_call_utils.py Verifies that callers cannot forge the reserved classifier-origin marker.
tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py Verifies spend-log projection and the unmarked ordinary-traffic case.

Reviews (3): Last reviewed commit: "feat(spend-logs): record when a spend lo..." | Re-trigger Greptile

Comment thread litellm/router_strategy/complexity_router/complexity_router.py Outdated
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tin-berri
tin-berri force-pushed the litellm_lit5014_classifier_call_badge branch from b61fe85 to 08722d3 Compare July 31, 2026 01:18
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you score this PR and explain why

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread litellm/router_strategy/complexity_router/complexity_router.py Outdated
@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit5014_classifier_call_badge (f1481c3) with litellm_internal_staging (6e26087)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (7d97bbc) during the generation of this report, so 2593168 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

…n classifier call

The complexity router's classifier sub-call copies the parent request's metadata
verbatim, so its spend log row carries the caller's key, team and user and is
indistinguishable from traffic the caller actually sent. Nothing on the row says
otherwise: call_type is "acompletion" either way, model_group is overwritten to the
classifier's own model group so the row never looks auto-routed, and routing_decision
is absent exactly as it is on an ordinary request.

Record the fact the system already knows at call time. internal_call_origin is
declared on SpendLogsMetadata, which is the allowlist _get_spend_logs_metadata
projects onto, and stamped in _classifier_call_metadata; both classifier paths
already route through that one function and it feeds the metadata and
litellm_metadata buckets alike, so every request surface is covered at one site.
The key is reserved rather than caller-supplied, so it joins routing_decision in the
untrusted-metadata strip and a caller cannot label their own traffic as router
overhead.

The classifier call also inherited no session identity, so the router minted a fresh
trace id and the row landed in a session of its own. Forwarding the parent's session
puts it in the trace of the request that triggered it, which is where an operator
looks for what the routing cost.
@tin-berri
tin-berri force-pushed the litellm_lit5014_classifier_call_badge branch from 08722d3 to f1481c3 Compare July 31, 2026 01:56
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you score this PR and explain why

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit f1481c3. 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. Thanks!

@tin-berri
tin-berri merged commit 2dbcb9a into litellm_internal_staging Jul 31, 2026
83 checks passed
@tin-berri
tin-berri deleted the litellm_lit5014_classifier_call_badge branch July 31, 2026 02:26
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.

2 participants