Skip to content

feat(router): treat configured finish reasons as deployment failures - #41825

Open
paoloantinori wants to merge 10 commits into
BerriAI:mainfrom
paoloantinori:treat-finish-reason-as-failure
Open

paoloantinori wants to merge 10 commits into
BerriAI:mainfrom
paoloantinori:treat-finish-reason-as-failure

Conversation

@paoloantinori

Copy link
Copy Markdown

Why

A provider can report a terminal condition as a stop reason on an HTTP 200. When a z.ai GLM Coding Plan account runs out of credits, every response comes back 200 with stop_reason "model_context_window_exceeded" and empty content: no failure is counted, allowed_fails never trips, cooldown never engages, fallbacks never fire (#38535 has the outage evidence). Upstream already special-cases one reason this way: #39157 raises on the Anthropic safeguard refusal in the generic dispatch, and #39274 arms content-filter fallbacks on default fallbacks alone. This PR generalizes that pattern into operator config.

Scope

  • Router(treat_finish_reason_as_failure={"model_context_window_exceeded": "RateLimitError"}): a reason to exception-name map, allowlisted and validated at construction, accepted from router_settings yaml through the existing get_valid_args path.
  • Detection at the _completion and _acompletion checkpoints, and in _ageneric_api_call_with_fallbacks_helper gated on anthropic_messages (reading the raw stop_reason before any translation). Detection matches both the mapped finish reason and the pre-mapping value in provider_specific_fields["native_finish_reason"].
  • A mapped reason always counts and parks like a failure: the same increment plus _set_cooldown_deployments pair that deployment_callback_on_failure runs, honoring a deployment-level cooldown_time. The raise into the fallback chain fires only when a generic fallback can serve. Raising sites account explicitly, because a post-200 raise never flows through litellm's failure callbacks.
  • The anthropic chat transform now stashes native_finish_reason through the shared map_finish_reason_and_stash_native, matching what Choices.__init__ already does for other providers.
  • Out of scope: streaming detection (follow-up modeled on _aanthropic_messages_streaming_iterator), any map_finish_reason change (the global passthrough stays closed per fix(core): preserve unmapped finish_reason and add 'refusal' mapping #23800), the refusal special case.
  • Known limitation: litellm's success event has already fired by the time the reason is inspected, so spend logging records the request as a success while availability metrics record a failure.

Tradeoffs

Reading the pre-mapping reason instead of changing the mapping keeps the knob opt-in; #23800 settled against a global passthrough. Explicit accounting at the raise sites does more than the content-filter path does today, which cools nothing without callbacks; that engagement is the issue's core ask.

Blast Radius

Zero behavior change unless the knob is configured, except the native_finish_reason stash, which makes the anthropic chat path consistent with the other providers.

Verification

tests/router_unit_tests/test_router_finish_reason_failure.py: fallback fires and cooldown engages on both dispatch shapes; a no-fallback config returns the response unchanged and still parks the deployment; the knob unset keeps the old behavior; an unknown exception name fails at construction. Five tests green, and test_router_anthropic_messages_fallback.py stays green (23 passed). Refs #38535.

- one _handle_mapped_finish_reason_failure (account, gate, raise) replaces the
  duplicated checkpoint blocks and the double exception build
- _generic_fallback_available shared by the refusal and knob availability gates
- accounting honors a deployment-level cooldown_time like
  deployment_callback_on_failure
- map+stash of the native finish reason lives in one helper
  (map_finish_reason_and_stash_native) shared by Choices.__init__ and the
  anthropic transform
- knob-off requests pay a single None check at every checkpoint
- warn at construction that the knob applies to non-streaming responses only
- warn when a key names a healthy terminal reason in the mapped OpenAI set
- a deployment without model_info id still raises into the fallback chain
  when one can serve; only the accounting needs the id
@paoloantinori
paoloantinori requested a review from a team September 18, 2026 15:09
@CLAassistant

CLAassistant commented Sep 18, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@codspeed

codspeed Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing paoloantinori:treat-finish-reason-as-failure (8c59d25) with main (fd58c31)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 3/5

This PR is not safe to merge until multi-choice detection, persisted-setting validation, and the explicit repository rule violations are addressed

Findings

  1. P1 Later Choices Bypass Detection
  2. P1 Invalid Settings Break Restart
  3. P2 Narrative Comments Violate Policy
  4. P2 Parameters Violate Typing Policy
  5. P2 Global Monkeypatch Violates Policy

Summary

This PR adds configurable classification of non-streaming finish reasons as deployment failures, including explicit failure accounting, cooldown, and generic fallback handling

  • Preserves provider-native Anthropic finish reasons during chat response normalization
  • Detects configured reasons in chat completion and Anthropic Messages responses
  • Adds failure accounting and fallback tests
  • Leaves multi-choice detection and persisted-setting validation incomplete

Reviews (1) · Last reviewed commit: "fix(router): knob init warnings and rais..."

Comment thread litellm/router.py
if not self.treat_finish_reason_as_failure:
return None
if not (response.choices and len(response.choices) > 0):
return 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.

P1 Later Choices Bypass Detection Only choices[0] is inspected, so configured failures in later choices bypass cooldown and fallback handling

Knowledge Base Used: Model invocation runtime

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Keeping the choices[0] read: it matches the adjacent _should_raise_content_policy_error check this knob generalizes (same block, two lines above), and a single completion carries one terminal reason across its choices. Consistency with the sibling check is the behavior a reviewer can predict here.

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.

That’s fair. _should_raise_content_policy_error is the adjacent precedent in the same block, and this response shape carries one terminal reason across its choices, so reading choices[0] is intentional and predictable here. I’m withdrawing this finding; no change is needed.

Comment thread litellm/router.py Outdated
fallbacks: list = [],
context_window_fallbacks: list = [],
content_policy_fallbacks: list = [],
treat_finish_reason_as_failure: dict[str, str] | None = 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.

P1 Invalid Settings Break Restart /config/update persists this argument without validation. Invalid mappings are ignored live but fail the next Router startup

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Verified against the update path: /config/update validates keys, persists router_settings to the config store, and never constructs a Router in place, so an invalid value surfaces at the next startup. That is the existing behavior for every init-validated Router parameter, and the ValueError names the offending value, so the failure is loud and actionable. Nothing specific to this knob.

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.

Thanks for verifying. Since /config/update already persists init-validated router_settings without reconstructing Router, and construction raises an explicit ValueError on restart, this is existing behavior shared by other Router parameters rather than a regression specific to this knob. I withdraw the finding.

Comment thread litellm/router.py
Comment on lines +711 to +712
## Exception classes a treat_finish_reason_as_failure value may name: resolved from litellm at
## use time, validated at Router construction.

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 Narrative Comments Violate Policy These labels violate the repository rule limiting comments to essential complex logic, tool inputs, or TODOs. Remove them before merging

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!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Keeping the two module-level notes: the ## label blocks match the surrounding house style, including the ## CHECK CONTENT FILTER ERROR ## checkpoints this change extends. They carry the allowlist semantics a reader cannot get from the frozenset alone.

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.

That context is fair. These are concise semantic labels rather than narrative comments: they document the allowlist’s purpose and the healthy-reason warning, and they match the surrounding ## checkpoint style. I retract the comment; no change is needed.

Comment thread litellm/router.py Outdated
)
return resolved is not None

def _handle_mapped_finish_reason_failure(self, model: str, deployment: dict, reason: str, kwargs: dict) -> 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.

P2 Parameters Violate Typing Policy Bare dict and Any parameters violate the repository's fully typed requirement. Replace them with precise types before merging

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!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The new helper signatures use Mapping[str, Any], and the earlier bare dict parameters were converted in 2210f94: the repository's type-discipline gate holds every LIT rule at or below the base count on this branch. Any as the value type matches the file-wide convention.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You're right. The new helper signatures in litellm/router.py:8691 use Mapping[str, Any], and the earlier bare dict parameters were converted in 2210f94. Since Any matches the file-wide convention and the type-discipline count is not regressing, the typing finding is resolved. No further change is needed.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

async def _send(_client: httpx.AsyncClient, request: httpx.Request, **kwargs: Any) -> httpx.Response:
return await self.send(request, **kwargs)

monkeypatch.setattr(httpx.AsyncClient, "send", _send)

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 Global Monkeypatch Violates Policy Patching httpx.AsyncClient.send globally violates the dependency-injection testing rule. Use an injected transport before merging

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!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Keeping the transport patch: it mirrors the FakeAnthropicUpstream pattern in tests/router_unit_tests/test_router_anthropic_messages_fallback.py, the anchor file this suite was written against. The router does not forward caller-injected clients, so the transport is the seam that exercises the real pipeline.

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.

That context changes my assessment. The existing test_router_anthropic_messages_fallback.py establishes transport interception as the accepted seam for these router pipeline tests, and the monkeypatch fixture restores httpx.AsyncClient.send after each test. Since the router does not forward caller-injected clients, this is appropriate here. I withdraw the finding; no change is needed.

Comment thread litellm/router.py
litellm_router_instance=self,
deployment_id=deployment_id,
)
_set_cooldown_deployments(

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.

Low: Caller-controlled responses poison shared deployment health

Finish reasons can depend on caller-controlled input, such as prompt size or output limits. When one of those reasons is configured, a client can repeatedly trigger it to increment the deployment-wide failure counter and cool down the deployment for every tenant; with RateLimitError and multiple deployments, the cooldown can occur after one request. Keep fallback handling request-scoped for caller-induced reasons, or require a separate explicit option before applying them to shared failure and cooldown accounting.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Known and deliberate. The same caller-induced parking already applies to content_policy_fallbacks today (a client whose prompts trip content filters cools the deployment for everyone), so the knob inherits an existing operator tradeoff rather than introducing one. Construction warns when a key names a healthy terminal reason such as stop or length, which is the caller-controlled vector; mapping provider-quota reasons like model_context_window_exceeded is the intended use and is not caller-controlled.

@veria-ai

veria-ai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR updates the router to treat configured response finish reasons as deployment failures, integrating them into failure and cooldown handling.

One security issue remains open. Caller-influenced finish reasons can affect shared deployment health accounting, allowing a client to trigger cooldowns that disrupt routing for other tenants when the feature is configured. No issues have yet been addressed.

Open issues (1)

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

@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 38.09524% with 52 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/router.py 26.76% 52 Missing ⚠️

📢 Thoughts on this report? Let us know!

…lpers by name

- Mapping annotations and Final locals keep every LIT rule at or below the base count
- the stash helper builds under a new name; its mutable copy carries a reason
- direct helper calls in the test satisfy the router code-coverage name check
@paoloantinori

Copy link
Copy Markdown
Author

The documentation check stays red until BerriAI/litellm-docs#1544 lands: the validation test requires every Router.__init__ parameter to appear in the config_settings reference table, and that table lives in the litellm-docs repository rather than this one. The row for treat_finish_reason_as_failure is up for review there.

@paoloantinori

Copy link
Copy Markdown
Author

The proxy-endpoints / Run tests job on 8c59d25 timed out after 20 minutes: the log shows the suite at 99% by 18:01:46 with the last started test (test_x_api_key_header_sent_in_request in the guardrail hooks) never completing, and the step timeout killing the run 17 minutes later. No assertion failed and nothing in the diff is collected by this job's selection (tests/test_litellm/proxy/**; the changed router files only appear in the change-detection list). It looks like a hang in an unrelated test; a rerun needs maintainer rights, which I do not have on this repository. Could someone re-run the failed job?

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