Skip to content

feat(complexity_router): let the classifier see assistant turns and rate what a short reply approves - #35471

Merged
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit5080_classifier_assistant_context
Aug 1, 2026
Merged

feat(complexity_router): let the classifier see assistant turns and rate what a short reply approves#35471
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit5080_classifier_assistant_context

Conversation

@tin-berri

@tin-berri tin-berri commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • The auto-router's LLM classifier only saw user turns, so a conversation where the assistant states the difficulty ("here is the plan, it is complex, should I execute?") and the user answers "yes" was classified on the word "yes" and routed to the cheapest tier
  • The classifier rubric ended "Classify only the current message", which the model applied literally, so even difficulty stated in a prior user turn was discounted and "yes" still came back SIMPLE

How it solves it:

  • classifier_context_include_assistant_turns (default off) puts assistant turns in the context window, so classifier_context_window_size becomes the last N turns across both roles
  • The rubric now asks the classifier to rate the work the current message approves, judged in the conversation it continues, while still forbidding it to rate a quoted section as if that section were the request
  • classifier_tier_rubric lets an operator supply their own tier definitions, with the trust-boundary paragraph always appended

Relevant issues

  • Adds an opt-in knob so the complexity classifier can read assistant turns, not just user turns, when deciding a tier
  • Rewords the classifier rubric so a short approving reply is rated on the work it approves rather than on its own length
  • Adds an operator-supplied tier rubric, composed with a non-overridable paragraph that keeps caller text from acting as instructions

Follow-up to the context window added in #35185

Linear ticket

Resolves LIT-5080

Pre-Submission checklist

  • 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

Screenshots / Proof of Fix

Live proxy on port 4099, classifier is Haiku 4.5, tiers are Haiku 4.5 (SIMPLE/MEDIUM) and Sonnet 5 (COMPLEX/REASONING), real provider calls. return_raw_model_name: true so the response reports the model that actually served the request. Two auto-router routes differing only by the new flag:

  - model_name: auto-ctx-off          # and auto-ctx-on, identical but true
    litellm_params:
      model: auto_router/complexity_router
      complexity_router_config:
        classifier_type: llm
        classifier_llm_config: {model: haiku-classifier, timeout_ms: 20000}
        classifier_context_include_assistant_turns: false
        return_raw_model_name: true
        tiers: {SIMPLE: tier-cheap, MEDIUM: tier-cheap, COMPLEX: tier-strong, REASONING: tier-strong}

The reported conversation, sent to each route on each request surface:

for route in auto-ctx-off auto-ctx-on; do
  curl -s http://127.0.0.1:4099/v1/chat/completions -H "Authorization: Bearer sk-1234" \
    -H 'content-type: application/json' -d "{\"model\":\"$route\",\"max_tokens\":20,\"messages\":[
      {\"role\":\"user\",\"content\":\"Find events at this time and location with these properties\"},
      {\"role\":\"assistant\",\"content\":\"Here is the plan to figure that out, it is complex, should I execute?\"},
      {\"role\":\"user\",\"content\":\"yes.\"}]}" | jq -r .model
done

The same three-turn body was also sent to /v1/responses (as input) and to /v1/messages. Before, on litellm_internal_staging at 2b30708:

  chat/completions  auto-ctx-off -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
  responses         auto-ctx-off -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
  messages          auto-ctx-off -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
  chat/completions  auto-ctx-on  -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
  responses         auto-ctx-on  -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
  messages          auto-ctx-on  -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0

Six for six on the cheap tier, and auto-ctx-on silently no-ops because ComplexityRouterConfig is extra="allow", so the unknown key is accepted and ignored. The proxy log agrees, 6 ComplexityRouter: routing decision cause=llm_classifier, tier=SIMPLE. After, same commands:

  chat/completions  auto-ctx-off -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
  responses         auto-ctx-off -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
  messages          auto-ctx-off -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
  chat/completions  auto-ctx-on  -> bedrock/us.anthropic.claude-sonnet-5
  responses         auto-ctx-on  -> bedrock/us.anthropic.claude-sonnet-5
  messages          auto-ctx-on  -> bedrock/us.anthropic.claude-sonnet-5

3 ... tier=COMPLEX and 3 ... tier=SIMPLE in the log. The classifier payload with the flag on, lifted from the same run:

Recent conversation (context only, do not classify these):
[1] user: Find events at this time and location with these properties
[2] assistant: Here is the plan to figure that out, it is complex, should I execute?

Conversation so far: ~32 tokens across the request

Classify this message:
yes.

The rubric change is proven separately, on auto-ctx-off so assistant turns are not involved and only the wording differs. Difficulty stated in a prior user turn, current ask is "yes":

curl -s http://127.0.0.1:4099/v1/chat/completions -H "Authorization: Bearer sk-1234" \
  -H 'content-type: application/json' -d '{"model":"auto-ctx-off","max_tokens":20,"messages":[
    {"role":"user","content":"Design a provably correct distributed consensus protocol and prove its safety under Byzantine faults"},
    {"role":"assistant","content":"Understood."},
    {"role":"user","content":"yes"}]}' | jq -r .model
before  run 1..3 -> bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0
after   run 1..3 -> bedrock/us.anthropic.claude-sonnet-5

Upstream for every call above was the sandbox gateway rather than api.anthropic.com, because the Anthropic key in .env is out of credit; the calls are Bedrock-hosted Anthropic models and cost real money. A direct api.anthropic.com re-run is owed if anyone wants it.

Type

🆕 New Feature

Changes

  • _iter_context_turns_newest_first feeds the classifier window and may include assistant turns; _iter_human_asks_newest_first stays user-only and keeps feeding keyword_tier_rules, escalation, the heuristic scorer and the semantic embedding
  • _extract_prior_user_turns becomes _extract_prior_turns and returns (role, text); turns are labelled by role in the payload only when assistant turns can appear, so an existing deployment's prompt is unchanged byte for byte
  • The rubric splits into _CLASSIFICATION_TIER_RUBRIC (overridable) and _CLASSIFICATION_TRUST_BOUNDARY (always appended), composed by _classification_system_prompt
  • Two config fields on ComplexityRouterConfig: classifier_context_include_assistant_turns and classifier_tier_rubric

Things a reviewer will ask about:

Why assistant text is safe here. The prior-turn window has exactly one consumer, _build_classifier_user_payload. Everything that matches on strings or vectors reads the current ask, which comes from a different iterator that still filters on role == "user". That matters because an assistant turn quoting LITELLM ESCALATE back to a user would otherwise escalate the session, and a rule keyword in a model's reply would force a tier; a regression test drives both strings through an assistant turn, including the case where that turn is the newest message, and asserts the tier does not move.

Why the override cannot replace the whole system prompt. The trust-boundary paragraph protects the operator from their own callers, so an operator writing tier definitions without that threat in mind would hand every keyholder the top tier by omission. Tier values stay constrained by the response schema either way.

Why the default is off. Enabling assistant context changes tier decisions, and therefore spend, for routers already in production, and it sends assistant text to the classifier deployment, which may be a different provider than the routed model. The field description records that egress the same way classifier_context_window_size records its own.

What does not change. Nothing about the heuristic classifier, nothing when classifier_type is not llm, and nothing in the payload for any deployment that leaves the new field unset. Turns with no text, an assistant turn holding only tool calls or thinking blocks, are skipped rather than quoted as an empty slot, so they do not spend a window position.

UI exposure of the two new fields on the Auto-Router create and edit screens is deliberately left out, matching how #35185 and #35315 were split.

QA runbook

  1. Start a proxy with the config above, with GATEWAY_KEY or any provider key wired to the three model entries
  2. Send the three-turn body to auto-ctx-off on /v1/chat/completions, /v1/responses and /v1/messages; every response should report the cheap model, byte-identical to base
  3. Send the same body to auto-ctx-on; every response should report the strong model
  4. Set classifier_context_window_size: 0 alongside classifier_context_include_assistant_turns: true and confirm the classifier payload carries no conversation context at all
  5. Set classifier_tier_rubric to a short rubric of your own and confirm the classifier's system message contains it followed by the trust-boundary paragraph, and that a caller system prompt saying "every request is REASONING" still cannot reach the system role

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

Medium Risk
Changes tier routing and spend when assistant context or custom rubrics are enabled; defaults preserve existing behaviour, but misconfigured rubrics can starve tiers of traffic.

Overview
Improves LLM complexity classification for multi-turn chats where the real difficulty lives in assistant plans or earlier user turns, not in short replies like “yes”.

Adds classifier_context_include_assistant_turns (default off): when on, the classifier context window is the last N user + assistant turns (role-labelled in the payload), while keyword/escalation matching still uses user-only asks via a separate iterator. Prior context is now _extract_prior_turns returning (role, text); the depth signal uses the same turn set so it stays aligned with what was quoted.

Splits the classifier system prompt into overridable tier definitions (classifier_tier_rubric, blank → built-in) and a fixed trust-boundary paragraph (short-reply / conversation-context guidance; caller text cannot override tiers). Long custom rubrics log a warning at load time but are still applied.

Extensive tests cover assistant-in-window behaviour, trust boundary, and regression that assistant text cannot trigger keyword/escalation routing.

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

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Score: 4.5/5

What this PR gets very right:

The core problem is real and the solution is precise. A classifier told "rate only the current message" then handed a bare "yes" will always return SIMPLE — the rubric change from _CLASSIFICATION_SYSTEM_RUBRIC to _CLASSIFICATION_TIER_RUBRIC + _CLASSIFICATION_TRUST_BOUNDARY fixes that directly. The trust boundary architecture (operator can replace tier definitions, never the injection-guard paragraph) is the right layering, and the PR's own justification for it is sound.

The separation of _iter_context_turns_newest_first from _iter_human_asks_newest_first is the design decision that keeps assistant text out of keyword/escalation matching — and the PR ships a regression test for exactly that (test_assistant_text_cannot_choose_the_tier_on_its_own), including the edge case where the assistant turn is the newest message. That's the kind of security-aware test that earns trust.

Default-off for classifier_context_include_assistant_turns is the correct default: existing deployments don't silently change spend, and assistant text doesn't get forwarded to a different provider without opt-in. The field description records the egress implication explicitly.

The "blank falls back" behavior for classifier_tier_rubric is a good UX detail for the form case.

What pulls it below 5:

  1. has_prior_conversation check widens the iterator unnecessarily. At _classify_with_llm lines 744–746, the has_prior_conversation guard calls _iter_context_turns_newest_first with include_assistant to decide whether to print the depth line — but the depth line's purpose is to say "this message is a follow-up, not a cold start," which is a property of the human conversation, not of the model's replies. Counting a lone assistant turn as evidence of a prior conversation could surface the depth line on a prefill-request shape where the only prior turn is an assistant turn with no prior user ask.

  2. classifier_tier_rubric has no length cap. An operator supplying a 50 000-token rubric will silently bloat every classifier call. A brief max_length on the Field or a trim in _classification_system_prompt would close that.

  3. _iter_context_turns_newest_first returns a genexp via return (...) rather than yield — the Iterator annotation is correct but the asymmetry with every other extraction function in the file is mildly surprising. Minor.

Overall this is well-engineered, well-tested, and the live-proxy proof in the PR description is unusually thorough. None of the concerns above are correctness blockers for the primary use case.

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR improves follow-up classification by optionally including assistant turns and rating short approvals in their conversational context

  • Adds an opt-in assistant-turn context window while preserving user-only keyword, escalation, heuristic, and embedding inputs
  • Adds configurable tier definitions while retaining a mandatory trust-boundary instruction
  • Expands focused tests for context extraction, prompt composition, role labeling, and routing safeguards

Confidence Score: 5/5

The PR appears safe to merge; no concrete blocking or independently actionable non-blocking issue remains

The assistant-context behavior is opt-in, existing user-only prompt formatting remains unchanged when disabled, supported message surfaces normalize textual blocks correctly, and the tests cover the newly introduced routing and prompt-composition boundaries

Important Files Changed

Filename Overview
litellm/router_strategy/complexity_router/complexity_router.py Adds role-aware classifier context extraction and configurable prompt composition without changing non-LLM or user-only routing inputs
litellm/router_strategy/complexity_router/config.py Adds default-off assistant context and optional classifier tier-rubric configuration with clear validation and egress descriptions
tests/test_litellm/router_strategy/test_complexity_router.py Adds focused regression coverage for assistant context, window semantics, prompt compatibility, and trust-boundary behavior

Reviews (1): Last reviewed commit: "feat(complexity_router): let the classif..." | Re-trigger Greptile

@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 5410d9b. Configure here.

@tin-berri
tin-berri force-pushed the litellm_lit5080_classifier_assistant_context branch from 5410d9b to 774df82 Compare August 1, 2026 18:50
@tin-berri

Copy link
Copy Markdown
Contributor Author

Thanks, all three checked. Pushed a test for the first one; declining all three, reasoning below.

1. has_prior_conversation iterator. Respectfully the opposite: on the prefill shape you describe, the window does quote that assistant turn, so a user-only gate would print a quoted conversation above a depth line claiming single-turn. That mismatch is the exact defect the depth gate was rewritten for in #35185. test_depth_signal_agrees_with_what_the_window_quoted now pins it and fails if the gate is switched to _iter_human_asks_newest_first.

2. Rubric length cap. No field on ComplexityRouterConfig carries an upper bound today (zero max_length/le=/lt= in config.py), including classifier_context_window_size, which bloats the same call. It is deploy-time config, not caller-reachable, so the operator pays their own bill and sees it on the first request. A hard config-load failure at an arbitrary threshold seemed worse than the cost it prevents, but happy to add one if you want the file to start bounding operator config.

3. return (genexp) vs yield. That is the file's existing convention, not a departure: _iter_human_asks_newest_first at complexity_router.py:212 is written identically, and the new iterator was matched to it deliberately.

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@tin-berri

Copy link
Copy Markdown
Contributor Author

Re-requesting on 774df82, the current head. The only change since 5410d9b is the added test_depth_signal_agrees_with_what_the_window_quoted regression test backing the first declined finding; no production code moved.

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

All three rebuttals hold up:

  1. has_prior_conversation iterator — correct. The test you added (test_depth_signal_agrees_with_what_the_window_quoted) pins the invariant precisely: when the only prior turn is an assistant turn and include_assistant=True, the window quotes it, so the depth gate must read the same iterator to avoid reporting "single-turn" while the payload shows a conversation. The user-only gate would be a concrete mismatch bug.

  2. Rubric length cap — accepted. No other config field carries a bound today, this is deploy-time not caller-reachable, and a hard config-load failure at an arbitrary threshold would be a worse surprise than the cost it prevents. The field description documents the egress risk.

  3. return (genexp) convention — accepted. _iter_human_asks_newest_first already uses this pattern; matching it is the right call.


Score: 4/5

The PR does what it says, does it safely, and the defaults preserve prior behavior byte-for-byte. The key risks are all handled:

  • assistant text is classifier-only — the user-only iterators for keyword/escalation/embedding are untouched, with regression tests for both the mid-conversation and trailing-assistant-turn cases
  • the trust boundary is always appended and is not part of the operator-overridable section
  • has_prior_conversation now agrees with what the window actually quotes

The one thing that keeps it from a 5 is that classifier_tier_rubric has no length guard. You acknowledged this as an intentional design decision and it's consistent with the rest of the config, but a very long rubric silently increases every classifier call's prompt token count in a way the operator may not notice until billing. A one-line @field_validator that emits a verbose_router_logger.warning (not a raise) when the rubric exceeds, say, 2 000 chars would cost very little and surface the issue early. That said, it's a minor gap in an otherwise well-executed PR.

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Score: 4/5

Here is the breakdown:

What works well:

  • Trust boundary is correctly non-overridable. _classification_system_prompt always appends _CLASSIFICATION_TRUST_BOUNDARY after any operator rubric. Blank/None falls back to built-in definitions rather than sending a rubric with no tiers. The response schema enforces the four-value enum regardless.
  • Keyword/escalation isolation is sound. _iter_context_turns_newest_first is a separate iterator from _iter_human_asks_newest_first; the diff makes clear that only _extract_prior_turns (→ classifier payload) reads the new one, while keyword rules, escalation, heuristic scorer, and semantic embedding all still read the user-only path. The test_assistant_text_cannot_choose_the_tier_on_its_own test, including the assistant-turn-is-the-newest-message parameterisation, is the right regression guard here.
  • has_prior_conversation consistency fix is legitimate. Before 774df82, has_prior_conversation used _iter_human_asks_newest_first even when include_assistant=True. That meant a conversation whose only prior turn was an assistant turn would have it quoted in the "Recent conversation" section but the depth line would say there was no prior conversation. The change to _iter_context_turns_newest_first(messages, include_assistant) makes the depth signal consistent with what the window actually quoted, and test_depth_signal_agrees_with_what_the_window_quoted pins that invariant correctly.
  • Backward compatibility is clean. Default False means existing deployments get a byte-identical prompt.
  • Tests discriminate. The parametrised pairs over the same conversation (same three messages, differ only by the flag) are the right shape: one row proves the feature works, the other proves the default doesn't regress.

Why not 5/5:

One structural concern that doesn't break correctness but is worth noting for a follow-up: classifier_tier_rubric is free text and could be set to a rubric that omits one or more tier names entirely. The response schema enforces the four-value enum so the router won't crash, but the classifier may hallucinate a distribution skewed toward whichever tiers the custom rubric mentions. There is currently no validation that a caller-supplied rubric references all four tiers, and no warning is emitted when it doesn't. That is a UX footgun rather than a security issue (the schema catches the worst outcome), but it is the one thing this PR leaves for an operator to discover the hard way.

The production code and the new test on 774df82 are correct. The depth-signal consistency fix is the right change and is now properly covered.

@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 774df82. Configure here.

@codecov

codecov Bot commented Aug 1, 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_lit5080_classifier_assistant_context branch from 774df82 to 5a74a19 Compare August 1, 2026 19:05
@tin-berri

Copy link
Copy Markdown
Contributor Author

Partial-rubric coverage: enumerated the state space rather than guessing at it. Unset and blank fall back to the built-in tiers; a rubric naming all four, a subset, or none is honoured as written, and the response schema keeps the value legal in every case. So the only surprising outcome is that tiers the rubric omits stop being chosen and their mapped models stop receiving traffic.

Documented that in the field description on 5a74a19 rather than validating it. A check that a rubric 'references all four tiers' has to sniff operator prose for tier names, which would both false-positive on wording like 'keep it simple' and reject a deliberate two-tier rubric, and it would fail config load on a heuristic. Same objection I had to a hard length cap.

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Score: 4/5

The PR is well-engineered and the defense on partial-rubric coverage is correct. Here's the breakdown:

What earns the 4:

  • Security boundary is solid. _iter_context_turns_newest_first and _iter_human_asks_newest_first are deliberately separate, and test_assistant_text_cannot_choose_the_tier_on_its_own covers both the mid-conversation case and the case where an assistant turn is the newest message — the exact regression path that matters.
  • Default-off is the right call. The flag changes spend for existing deployments and sends assistant text to the classifier, potentially a different provider. Both hazards are documented in the field description.
  • Trust boundary is non-removable for the right reason — it protects the operator from their own callers, not from themselves. The docstring explains this clearly.
  • Test coverage is discriminating. The "same conversation, flag on vs off" parametrized pair is the right way to prove the fix. The depth-signal consistency test and the window-of-zero test are the kinds of edge cases that bite you later.
  • Partial-rubric is documented, not validated, and that is the right call. A completeness check on operator prose would false-positive on incidental wording and would break deliberate two-tier rubrics. The field description says exactly what happens.

What keeps it from 5:

There's no runtime log or warning when the classifier returns a tier that has no mapped model in tiers — which is the silent failure mode for a partial rubric. That's a pre-existing gap, not introduced here, but since this PR adds a new path to reach it (classifier_tier_rubric naming a subset), a verbose_proxy_logger.warning when a returned tier isn't in the tiers map would close the gap cheaply. Not a blocker, but it's the difference between "I wrote an incomplete rubric and got no traffic" and "I wrote an incomplete rubric and got a log line explaining why".

@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 5a74a19. Configure here.

@tin-berri

Copy link
Copy Markdown
Contributor Author

Agreed it is a real gap, and declining it here on two grounds.

It is reachable today with no rubric involved at all: a tiers map of just SIMPLE and MEDIUM resolves a REASONING classification to the SIMPLE model silently, which I confirmed against get_model_for_tier on this branch. The rubric field adds a new way to arrive there, not the fallback behaviour itself.

More to the point, that function is being rewritten right now in open PR #35331, which replaces the unmapped-tier fallback with a resolution ladder and touches get_model_for_tier and _pick_from_tier_value directly. A warning added here would conflict with it and get rewritten, so the observability belongs on that PR where the new fallback semantics are actually decided.

@greptileai can you reply here with a score and why that score?

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

4/5. Here is the breakdown.

What earns it high marks:

  • The separation of _iter_context_turns_newest_first from _iter_human_asks_newest_first is the most important design decision in the PR and it is done correctly. Keyword matching, escalation, and the semantic scorer still only see user turns, so an assistant turn quoting LITELLM ESCALATE cannot hijack the tier. The test in test_assistant_text_cannot_choose_the_tier_on_its_own covers both the mid-conversation case and the case where an assistant turn is the newest message, which is the discriminating one.

  • The trust-boundary paragraph being non-overridable is the right call. The field description explains why: an operator who writes tier definitions without that paragraph in mind hands every keyholder the top tier by omission. Composing it in _classification_system_prompt rather than leaving it up to the caller is the correct enforcement point.

  • Default-off with an explicit field description recording the egress consequence (assistant text is sent to the classifier model, which may be a different deployment or provider) is the right posture for a change that shifts spend for existing deployments.

  • Test coverage is unusually thorough. The discriminating pairs (same conversation, flag differs by one value), the empty-text-turn slot test, the depth-signal consistency test, and the rubric pin against "Classify only the current message" reappearing are all correct regression targets.

What holds it to 4 rather than 5:

The PR ships a documented footgun: a classifier_tier_rubric that omits some tiers silently starves those models without any signal to the operator. The field description says "Describe every tier you want reachable," which is the right documentation, but there is no runtime warning. The developer's argument — that this fallback exists already through the tiers map and belongs to the get_model_for_tier rewrite in #35331 — is sound, and I am not flagging it as a blocker. But it means this PR ships with a gap that requires a follow-up to fully close, which is the small deduction.

@tin-berri

Copy link
Copy Markdown
Contributor Author

Filed the follow-up as LIT-5086 so the gap is tracked rather than left to a future reader: warn when a classified tier resolves through the fallback instead of its own entry, to land on top of #35331 once that rewrite settles. Ticket also notes this may be better as startup validation than a per-request log, since a partial tiers map is a static config fact and the request path is hot.

@codspeed-hq

codspeed-hq Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit5080_classifier_assistant_context (34f4354) with litellm_internal_staging (2b30708)1

Open in CodSpeed

Footnotes

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

…ate what a short reply approves

The LLM classifier's context window carried user turns only, so a conversation
whose difficulty was stated by the model rather than by the user was classified
without it. Asked to find events, the assistant answers "here is the plan, it is
complex, should I execute?", the user answers "yes", and the router rates the
word "yes" and picks the cheapest tier

Two independent causes, so two changes that are each provable on their own

classifier_context_include_assistant_turns adds assistant turns to the window.
It is off by default because turning it on shifts tier decisions, and therefore
spend, for an already-deployed router, and because assistant text is net-new
egress to the classifier deployment. With it on, classifier_context_window_size
counts the last N turns across both roles, which is what makes the assistant's
own statement of difficulty land in the window

Assistant text reaches the classifier payload and nothing else. The window is
read only by _build_classifier_user_payload, while keyword_tier_rules, escalation
matching, the heuristic scorer and the semantic embedding all read the human ask
through _iter_human_asks_newest_first. Those are substring and vector matchers,
so an assistant echoing an escalation keyword back to a user would choose the
model, and the spend, with nobody having asked. Rather than widen the shared
iterator, _iter_context_turns_newest_first is separate and feeds the window
alone, which makes the boundary structural instead of a rule to remember

The rubric ended "Classify only the current message", and the classifier applied
it literally: a request whose difficulty was established earlier came back SIMPLE
because the message being rated was the word "yes". A context window the rubric
then tells the model to disregard buys nothing, so the wording now asks it to
rate the work the current message approves, judged in the conversation it
continues, while still forbidding it to rate a quoted section as if that section
were the request

classifier_tier_rubric lets an operator replace the tier definitions. The
trust-boundary paragraph is appended and cannot be replaced: it defends the
operator against their own callers, so an operator writing tiers without that
threat in mind would otherwise hand every keyholder the top tier by omission.
Blank reads as unset so an empty form field falls back rather than sending a
rubric with no tiers in it

Turns are labelled by role only when assistant turns can appear, so the prompt of
every deployment that never asked for this is unchanged byte for byte
@tin-berri
tin-berri force-pushed the litellm_lit5080_classifier_assistant_context branch from 5a74a19 to 34f4354 Compare August 1, 2026 19:57
@tin-berri

Copy link
Copy Markdown
Contributor Author

Taken, on 34f4354. Worth separating the two proposals: what I declined earlier was a hard max_length, which fails config load on a threshold this router invented over the operator's own spend. A warning carries none of that, and it is the same shape I proposed for the partial-rubric case, so there is nothing to argue with here.

_warn_on_long_tier_rubric emits verbose_router_logger.warning above CLASSIFIER_TIER_RUBRIC_WARN_CHARS (2000) and returns the value unchanged. Parametrized over the boundary, and mutation-checked three ways: moving > to >=, removing the warning, and swapping the warning for a raise each fail the test, so the warn-and-still-honour contract is pinned rather than just the threshold.

@greptileai can you reply here with a score and why that score?

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

5/5

The _warn_on_long_tier_rubric addition is exactly the right shape for this. It warns, it honours, it returns the value unchanged, and the test pins all three properties independently — a > vs >= slip, a silent drop of the warning, and a raise-instead-of-warn each break a different assertion. That is the mutation coverage the contract needs: not just "a warning fires", but "a warning fires and the value survives it".

The broader PR holds up the same way:

  • The security invariant (assistant turns reach the classifier payload and nowhere else) is tested with two discriminating rows — assistant turn mid-conversation and assistant turn as the newest message — and both assert that neither LITELLM ESCALATE nor a keyword tier rule in the assistant text moves the decision.
  • The depth-signal/window consistency test covers the exact mismatch the PR description flags: a conversation whose only prior turn is an assistant turn would report itself as single-turn when has_prior_conversation counted user turns only, while the payload above it quoted the assistant. That defect is closed and pinned.
  • Existing deployments are byte-identical: the flag defaults off, and with it off the prior-turn labels ([1] user: …) do not appear, so the prompt is unchanged character for character from the pre-PR build.
  • _classification_system_prompt composition is principled: operator text replaces the tier block, the trust-boundary paragraph is always appended, and test_rubric_rates_the_work_a_short_reply_approves pins the exact strings that caused the original mis-routing so the regression cannot silently re-enter via a wording change.

No issues to raise.

@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 34f4354. Configure here.

@tin-berri
tin-berri enabled auto-merge (squash) August 1, 2026 20:49
@tin-berri
tin-berri merged commit 9bed495 into litellm_internal_staging Aug 1, 2026
80 checks passed
@tin-berri
tin-berri deleted the litellm_lit5080_classifier_assistant_context branch August 1, 2026 20:59
tin-berri added a commit that referenced this pull request Aug 1, 2026
…o-Router screens

PR #35471 added classifier_context_include_assistant_turns and classifier_tier_rubric
to ComplexityRouterConfig. Both worked through config.yaml and the model API but had
no control on the Add Model or Edit Auto-Router screens, so an operator working from
the dashboard could not reach either. Wires both into the create and edit forms,
shown only when the LLM classifier is selected, matching what #35315 did for the two
context-window fields

The create and edit stacks share the rendered control but keep their own serializer,
their own hydration, and their own managed-key set, so each field is added in five
places rather than one. A field wired into only one stack fails in a way neither
serializer unit test can see, since those are handed a form value assembled by hand,
so the edit-modal test drives the real component through open, edit and save

A blank rubric is sent as omitted rather than as an empty string, in both serializers.
The backend reads blank as unset and falls back to its built-in tier definitions, so
storing "" would round-trip as a value that means nothing and reappear in the form as
content. The assistant-turns switch is emitted even when false, because there the
operator turning it off is a choice worth persisting rather than an absent value
tin-berri added a commit that referenced this pull request Aug 1, 2026
…-Router screens

PR #35471 added classifier_context_include_assistant_turns to ComplexityRouterConfig.
It worked through config.yaml and the model API but had no control on the Add Model or
Edit Auto-Router screens, so an operator working from the dashboard could not reach it.
Wires it into the create and edit forms, shown only when the LLM classifier is
selected, matching what #35315 did for the two context-window fields

The create and edit stacks share the rendered control but keep their own serializer,
their own hydration, and their own managed-key set, so the field is added in five
places rather than one. A field wired into only one stack fails in a way neither
serializer unit test can see, since those are handed a form value assembled by hand,
so the edit-modal test drives the real component through open, edit and save

The switch is emitted even when false, because there the operator turning it off is a
choice that has to overwrite a stored true rather than an absent value a truthiness
gate would drop
tin-berri added a commit that referenced this pull request Aug 1, 2026
classifier_tier_rubric let an operator replace the classifier's tier definitions. It
shipped in #35471 alongside the assistant-turn context window, but the two answer
different halves of the same report and only the context window was asked for. The
override carried its own surface to maintain: a system prompt composed from an
overridable half and a non-overridable one, a blank-is-unset rule, a length-warning
validator, and a matching pair of dashboard controls

Removing it puts the classifier's system prompt back in the single constant it was
before. The prompt an existing deployment sends is unchanged: the composed default and
the restored constant are byte-identical, so the trust-boundary paragraph moves back
inside the constant rather than being edited. Nothing about the wording, the quoted
conversation, the context window or routing changes here

A config still carrying a classifier_tier_rubric key keeps loading, since
ComplexityRouterConfig is extra="allow"; the key is ignored from here on
tin-berri added a commit that referenced this pull request Aug 1, 2026
… rubric on the window it was given

Two changes to the classifier's system role, both narrowing it rather than adding to it

classifier_tier_rubric let an operator replace the tier definitions. It shipped in
#35471 alongside the assistant-turn context window, but the two answer different halves
of the same report and only the context window was asked for. The override carried a
composed prompt, an overridable and a non-overridable half, a blank-is-unset rule, a
length-warning validator and a pair of dashboard controls. All of it goes

The rubric then closes on one of two lines, chosen by classifier_context_window_size.
At 0 no conversation is quoted, so the line is the original one, byte for byte: a
deployment that sends no context is told to classify the current message and nothing
else, which is what it could see all along. Above 0 the turns are quoted, and the
original line told the model to disregard them, which is how a request whose difficulty
was established in an earlier turn came back SIMPLE on the word "yes". There the line
instead says to classify the current message using the quoted turns as context, and to
rate what a short reply approves rather than the reply

The choice keys on the window and not on classifier_context_include_assistant_turns.
Whether the quoted turns are the user's alone or include the assistant's replies does
not change what the model needs told, and whose turn is whose is already on the turns.
Keying it on the assistant toggle would put the default deployment back on the original
line, which is the configuration the report was raised against

Folds in #35508, which built the window-dependent framing on top of the override this
removes; that PR is closed in favour of this one
tin-berri added a commit that referenced this pull request Aug 1, 2026
…-Router screens (#35500)

PR #35471 added classifier_context_include_assistant_turns to ComplexityRouterConfig.
It worked through config.yaml and the model API but had no control on the Add Model or
Edit Auto-Router screens, so an operator working from the dashboard could not reach it.
Wires it into the create and edit forms, shown only when the LLM classifier is
selected, matching what #35315 did for the two context-window fields

The create and edit stacks share the rendered control but keep their own serializer,
their own hydration, and their own managed-key set, so the field is added in five
places rather than one. A field wired into only one stack fails in a way neither
serializer unit test can see, since those are handed a form value assembled by hand,
so the edit-modal test drives the real component through open, edit and save

The switch is emitted even when false, because there the operator turning it off is a
choice that has to overwrite a stored true rather than an absent value a truthiness
gate would drop
tin-berri added a commit that referenced this pull request Aug 1, 2026
… rubric on the window it was given (#35504)

Two changes to the classifier's system role, both narrowing it rather than adding to it

classifier_tier_rubric let an operator replace the tier definitions. It shipped in
#35471 alongside the assistant-turn context window, but the two answer different halves
of the same report and only the context window was asked for. The override carried a
composed prompt, an overridable and a non-overridable half, a blank-is-unset rule, a
length-warning validator and a pair of dashboard controls. All of it goes

The rubric then closes on one of two lines, chosen by classifier_context_window_size.
At 0 no conversation is quoted, so the line is the original one, byte for byte: a
deployment that sends no context is told to classify the current message and nothing
else, which is what it could see all along. Above 0 the turns are quoted, and the
original line told the model to disregard them, which is how a request whose difficulty
was established in an earlier turn came back SIMPLE on the word "yes". There the line
instead says to classify the current message using the quoted turns as context, and to
rate what a short reply approves rather than the reply

The choice keys on the window and not on classifier_context_include_assistant_turns.
Whether the quoted turns are the user's alone or include the assistant's replies does
not change what the model needs told, and whose turn is whose is already on the turns.
Keying it on the assistant toggle would put the default deployment back on the original
line, which is the configuration the report was raised against

Folds in #35508, which built the window-dependent framing on top of the override this
removes; that PR is closed in favour of this one
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