Repository navigation
fix(bedrock_mantle): map the OpenAI-shaped context overflow envelope too - #41747
mateo-berri merged 20 commits into
Conversation
…ticated requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…stency Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…nd Model Hub UI Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…mutation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ow read fails Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…iption feat(model_hub): surface model_info.description in Model Hub
…ip_save_on_failed_read fix(health): skip background health check DB writes when the latest-row read fails
feat(dd_span_tagger): emit litellm.user_email span tag for JWT-authenticated requests
Mantle reports context overflow in two envelopes. BerriAI#37862 covered the structured `validation_error` one carrying token counts ("prompt tokens (N) exceed model maximum (M)"). Mantle also returns the OpenAI-shaped body: {"error":{"code":"context_length_exceeded","message":"Your input exceeds the context window of this model. ...","param":"input", "type":"invalid_request_error"}} That passes the existing `invalid_request_error` gate but matches neither the token-count pattern nor `is_error_str_context_window_exceeded`'s substring list, so it fell through to a generic BadRequestError. Clients such as Claude Code only reactive-compact on a recognised overflow phrase, so an overflowing request surfaced as an ordinary error with no recovery. Return the same normalised "prompt is too long: ..." message for this envelope when it carries no counts, keeping the numeric wording when the provider supplies them.
|
Merging this PR will not alter performance
|
|
Follow-up to #37862, which covered only one of the two envelopes Mantle uses for context overflow. Original report: #36546 The other envelope, reproduced live against a LiteLLM proxy in front of {"error":{"code":"context_length_exceeded",
"message":"Your input exceeds the context window of this model. Please adjust your input and try again.",
"param":"input","type":"invalid_request_error"}}It passes the This change returns the same normalised |
|
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Rebuilds the branch on current main so the PR diff is only the Mantle overflow mapping and its test. Drops the stale litellm_internal_staging drift (BerriAI#37762, BerriAI#40993, BerriAI#34939) the branch had picked up.
…00 on /v1/messages streams
|
bugbot run |
…med /v1/messages so other provider 4xx still fall back
|
bugbot run |
…ss routes, clients, and fallbacks
|
bugbot run |
…into a BUG-skipped test
|
bugbot run |
…he missing spend row
|
bugbot run |
| @@ -0,0 +1,766 @@ | |||
| import json | |||
There was a problem hiding this comment.
Tests split across files This bug fix adds two integration files despite existing Mantle overflow coverage. Repository guidance requires extending the existing test file before merging
Context Used: AGENTS.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!
There was a problem hiding this comment.
That rule covers tests/unit mirroring litellm. tests/integration lays files out per scenario, like the other Mantle files; the existing file holds one pre-stream 400 cell
There was a problem hiding this comment.
You’re right — this is an integration-test organization convention, not a duplication problem. test_bedrock_mantle_wire.py contains the existing legacy token-count overflow and unrelated Mantle wire checks, while test_bedrock_mantle_context_overflow_wire.py groups the new OpenAI-shaped overflow scenario and its route, client, and streaming coverage. The separate file is appropriate here, so I’m withdrawing that comment.
The Mantle overflow mapper built its ContextWindowExceededError on the original exception's response, so an overflow that Mantle reports inside a 200 stream (the chat-mode token-count envelope, which the OpenAI chunk parser wraps as a 500) kept that 500 and the streamed /v1/messages route answered HTTP 500 api_error instead of 400 invalid_request_error. The mapper now builds the error on a 400 response that keeps the original headers and request, and the mapping test covers an in-stream 500 and 503. The integration suites pin the streamed /v1/messages overflow to the pre-content 400 route under a retry budget and to the message_start plus invalid_request_error event without one, drop the chaos carve-out that expected no spend row, and give the fallbacks file a timeout mark sized to the proxy's graceful stop.
|
|
||
| def _error_status_and_message(exc: Exception) -> tuple[int, str]: | ||
| if isinstance(exc, (BaseLLMException, MidStreamFallbackError)): | ||
| if isinstance(exc, (BaseLLMException, MidStreamFallbackError, ContextWindowExceededError)): |
There was a problem hiding this comment.
🔴 Streamed overflow loses failure spend logs
When a Mantle stream reports overflow, ContextWindowExceededError becomes a 400 error frame. The router passes that frame through without running the proxy failure hook, leaving no spend log for the rejected call.
Learn more
A streamed Responses API failure can arrive as a response.failed event rather than an HTTP error. _map_stream_error_to_exception now maps Mantle's overflow event to a 400 context error. This branch emits a 400 Anthropic frame. The router frame gate forwards non-retryable 400 frames without raising; async_anthropic_sse_wrapper does not call the proxy failure hook when it emits an error frame. The proxy therefore never records a failed spend row for this request.
Example: A Mantle deployment returns response.failed with code=context_length_exceeded during a streamed /v1/messages request. The client receives a 200 SSE response with an invalid_request_error event, but the request has no LiteLLM_SpendLogs failure row.
Recommended fix: Ensure the proxy-managed Anthropic stream error-frame path calls the failure hook exactly once for the mapped provider error, including when the router forwards a non-retryable frame. Keep the 400 frame for client compaction and add an integration assertion for a failed spend row on this route.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
The adapter dispatches failure handlers before emitting the error frame, so the failure spend row is still written, and test_messages_stream_overflow_logs_one_failure_row pins it
There was a problem hiding this comment.
Live check at 027f769, the streamed /v1/messages overflow wrote a failure spend row with ContextWindowExceededError on both Mantle models
| if "context_length_exceeded" in error_str: | ||
| return _BEDROCK_MANTLE_CONTEXT_WINDOW_GENERIC_MESSAGE | ||
| return None |
There was a problem hiding this comment.
🟡 Quoted error codes trigger false overflow
When a Mantle validation error quotes context_length_exceeded in its message, _get_bedrock_mantle_context_window_message treats it as overflow. Valid requests can then trigger context-window fallbacks or compaction instead of returning the actual validation error.
Learn more
The mapper receives a stringified provider exception, not a parsed error code. It accepts the marker anywhere in that string without checking the structured error.code value or HTTP status. A provider rejection that quotes caller-supplied text containing the marker therefore becomes a 400 overflow. The removed type gate also broadens the legacy token-count recognition to error types that were previously excluded.
Example: A Mantle 400 body {"error":{"code":"invalid_request_error","message":"Invalid input value context_length_exceeded"}} is now returned as ContextWindowExceededError. Its actual code does not identify context overflow.
Recommended fix: Parse the envelope or inspect the exception's structured body/code and compare the exact error.code to context_length_exceeded; retain a narrowly scoped token-count fallback for the legacy validation envelope. Preserve existing mapping for other statuses and codes.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Since 027f769 the mapper matches context_length_exceeded only as the envelope's code key, so a message merely quoting it stays a plain 400
…low code only as error.code
|
bugbot run |
There was a problem hiding this comment.
✅ 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 027f769. Configure here.
mateo-berri
left a comment
There was a problem hiding this comment.
LGTM. Thanks for the contribution!
TLDR
Problem this solves:
context_length_exceededBadRequestErrorwith the raw provider text/compactitself failsHow it solves it:
context_length_exceededmaps toContextWindowExceededErrorprompt is too long, which Claude Code recognizescontext_window_fallbacksfire for this overflow where the router consults them/v1/messagesstream adapter's own error event keeps that error's 400 and message, not a 500e.responseon the mapped error is the provider's own 400 response, so an SDK caller still reads the envelopeIntentional product change: a Mantle overflow now reads
prompt is too long: ...instead of the rawcontext_length_exceededbody. A client that matched the raw provider text loses that match and gets litellm's standard overflow error instead. With acontext_window_fallbackslist inrouter_settings, that overflow no longer takes the deployment's plainfallbacksentry, the router's rule for every provider's overflow, so add the Mantle group tocontext_window_fallbacksUser Flow
Before: Claude Code never compacts after a Bedrock Mantle overflow, so the turn dies on an API error
ANTHROPIC_BASE_URLat their LiteLLM proxy andANTHROPIC_MODELset to abedrock_mantle/openai.gpt-5.6-lunadeployment"stream": true{"type":"error","error":{"type":"invalid_request_error","message":"litellm.BadRequestError: BedrockException - Error code: 400 - {'error': {'message': 'Your input exceeds the context window of this model. Please adjust your input and try again.', 'type': 'invalid_request_error', 'code': 'context_length_exceeded'}}"}}API Error: 400 litellm.BadRequestError: BedrockException - Error code: 400 - ..., the turn ends and nothing compacts; the same prompt on POST /v1/chat/completions or /v1/responses gets a 400BadRequestErrorcarrying the raw provider textAfter: the overflow comes back as a
prompt is too longerror, so Claude Code compacts and carries onANTHROPIC_BASE_URLat their LiteLLM proxy andANTHROPIC_MODELset to abedrock_mantle/openai.gpt-5.6-lunadeployment"stream": true{"type":"error","error":{"type":"invalid_request_error","message":"litellm.ContextWindowExceededError: litellm.BadRequestError: prompt is too long: your prompt exceeds the model's context window"}}Conversation compacted, resends the trimmed conversation, and the turn completes; POST /v1/chat/completions and /v1/responses answer 400 with the sameprompt is too longmessageRelevant issues
Follow-up to #36580 and #37862, which mapped Mantle's other overflow envelope (
prompt tokens (N) exceed model maximum (M)). Original report: #36546. Downstream report: #36580 (comment)Linear ticket
Resolves LIT-8944
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/unit/<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@greptileaito 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).
Design decisions
Mantle reports context overflow in two envelopes. #37862 covered the first, a
validation_errorwith token counts:{"error":{"code":"validation_error", "message":"prompt tokens (1055489) exceed model maximum (1050000) for openai.gpt-5.6-sol", "param":null,"type":"invalid_request_error"}}Mantle also returns the OpenAI-shaped body, reproduced live against a LiteLLM proxy in front of Mantle:
{"error":{"code":"context_length_exceeded", "message":"Your input exceeds the context window of this model. Please adjust your input and try again.", "param":"input","type":"invalid_request_error"}}A streamed call gets the same thing as a
response.failedevent carrying onlycodeandmessage, with notype. The second shape passed the oldinvalid_request_errorgate but matched neither the token-count regex nor the shared overflow substring list, so it landed in the genericBadRequestErrorbranch carrying the raw provider textClaude Code only runs its reactive compaction when the error reads as an overflow: its 400 classifier matches
prompt is too longorinput is too long for requested model, andcontext windowonly at 413. So an overflow in this second shape died as an ordinary API error and the conversation could never compact, which is what produced the downstream/compactfailure:The match keys on the
context_length_exceededcode alone, read as the envelope'scodekey in any of the three serializations the error text can carry (plain JSON, the Python repr'code': 'context_length_exceeded', and the escaped JSON a message quotes). It is in both wire shapes and it is OpenAI's documented overflow code. Matching the English message was the alternative, but that wording is Mantle's to change, and requiring both code and text adds nothing the code does not already say. A Mantle 400 whose message merely contains the wordscontext_length_exceeded(a validation error quoting a field value, say) stays a plainBadRequestErrorwith the upstream message, so a valid request cannot be steered into compaction or a context-window fallback by its wording. The mapped error reuses the provider's own 400 response object, status, headers and body intact, and synthesizes a 400 with the original headers and request only when the original status is not 400 (the in-stream chunk-parser error, which carries no body on either side), soe.response.json()on the SDK keeps workingThe
invalid_request_error/validation_errorgate that used to wrap this helper is gone, because the streamed event has notypeand the gate silently skipped it. The token-count match still runs first, so that envelope keeps itsprompt is too long: N tokens > M maximumwording with real numbers. The new shape carries no counts, so it gets the fixedprompt is too long: your prompt exceeds the model's context window, and clients parse the trailingN tokens > M maximumonly when it is there, falling back to their own conversation accounting otherwiseNon-overflow Mantle errors (for example
Invalid 'input': value did not match any expected variant) still map toBadRequestError, and the existing test for that is unchanged. Mutation check: deleting the new branch makes the new test fail with the rawBadRequestError, so the test defends the behavior rather than the implementationOn streamed
/v1/messagesthe proxy buffers the first event, and since #44800 (merged into this branch) a provider error raised before any content reaches the client is answered as a JSON error carrying that error's own status, which is the HTTP 400 both legs below show. Before #44800 the same overflow surfaced as a pre-stream HTTP 500 that Claude Code retried ten times, the shape in the original report. The adapter's own error event, what the library's/v1/messagesstream wrapper emits when no proxy manages the stream, used to turn every mapped litellm exception into a 500api_error; it now passes through the status and message ofContextWindowExceededErrortoo, so that path also reads as a 400 overflow, and the other mapped litellm exceptions keep their 500 there (both covered by the adapter's unit tests)Screenshots / Proof of Fix
Last updated: 027f769, the PR merged with main 249add6. The After ran on 027f769 against that merge base, the Before on the earlier merge base 988cc7d. The audit matrix below names the commits it ran at
Shared setup. One proxy per leg, booted from its own venv with a Postgres database and 2 uvicorn workers, each on its own random port:
The overflow payloads carry 4,150,000 characters of repeated text in one user turn, which Mantle rejects as over the window; the last four payloads are the regression guard and the control:
Every curl below has the same shape, printing the body and then the status code (
/v1/messagescalls add-H 'anthropic-version: 2023-06-01'):Claude Code 2.1.296 ran interactively in tmux against each proxy, with
CLAUDE_CODE_MAX_CONTEXT_TOKENS=2000000so its own proactive auto-compact stays out of the way and the overflow reaches the provider:Two pastes of about 2.5 MB each,
This is a test sentence.repeated 100,000 times followed byReply with just OK. Part A alone fits the window (about 590,000 tokens, answeredOK), part B on top of it does notBefore (988cc7d)
Claude Code
OKAPI Error: 400 litellm.BadRequestError: BedrockException - Error code: 400 - {'error': {'message': 'Your input exceeds the context window of this model. Please adjust your input and try again.', 'type': 'invalid_request_error', 'code': 'context_length_exceeded'}}, the turn ends, and nothing compactsPOST /v1/messages, stream
curl ... /v1/messages --data-binary @messages_stream.json400, a JSON body and no stream:POST /v1/messages
curl ... /v1/messages --data-binary @messages.json400:POST /v1/chat/completions, stream
curl ... /v1/chat/completions --data-binary @chat_stream.json400:POST /v1/chat/completions
curl ... /v1/chat/completions --data-binary @chat.json400:POST /v1/responses, stream
curl ... /v1/responses --data-binary @responses_stream.json200,response.created,response.in_progress, thenevent: response.failedwhoseresponse.erroris:POST /v1/responses
curl ... /v1/responses --data-binary @responses.json400:Other-provider 4xx fallbacks on streamed /v1/messages and /v1/chat/completions (regression guard)
curl ... /v1/messages --data-binary @gem_temp_messages_stream.json(Gemini rejectstemperature: 7with a 400,fallbackspoints atgem-good)400, Gemini's own error and no fallback:{"type":"error","error":{"type":"invalid_request_error","message":"litellm.BadRequestError: Vertex_ai_betaException BadRequestError - b'{ \"error\": { \"code\": 400, \"message\": \"* GenerateContentRequest.generation_config.temperature: temperature must be in the range [0.0, 2.0].\", \"status\": \"INVALID_ARGUMENT\" } }'"}}curl ... /v1/chat/completions --data-binary @gem_temp_chat_stream.json200, every chunk carries"model":"gem-good"and the textHi there!curl ... /v1/messages --data-binary @gem_404_messages_stream.json(the model does not exist,fallbackspoints atgem-good)404, Gemini's own error and no fallback:{"type":"error","error":{"type":"not_found_error","message":"litellm.NotFoundError: Vertex_ai_betaException - b'{ \"error\": { \"code\": 404, \"message\": \"models/gemini-no-such-model-9 is not found for API version v1alpha, or is not supported for generateContent. ...\", \"status\": \"NOT_FOUND\" } }'"}}Control, a small streamed /v1/messages call
curl ... /v1/messages --data-binary @small_messages_stream.json200,message_start,content_block_start, twocontent_block_delta,content_block_stop,message_deltawith"stop_reason": "end_turn",message_stop; the text isHi thereAfter (027f769)
Claude Code
OKPOST /v1/messages?beta=trueanswered400 Bad Requestwithlitellm.ContextWindowExceededError: ... prompt is too long: your prompt exceeds the model's context window, the pane showsConversation compacted (ctrl+o for history), Claude resends the trimmed conversation (1,100,337 tokens compacted to 539,650 in 20.7 s) and answersOK25 s after the submitPOST /v1/messages, stream
curl ... /v1/messages --data-binary @messages_stream.json400, a JSON body and no stream, 2.3 s:POST /v1/messages
curl ... /v1/messages --data-binary @messages.json400:POST /v1/chat/completions, stream
curl ... /v1/chat/completions --data-binary @chat_stream.json400:POST /v1/chat/completions
curl ... /v1/chat/completions --data-binary @chat.json400:POST /v1/responses, stream
curl ... /v1/responses --data-binary @responses_stream.json200,response.created,response.in_progress, thenevent: response.failedwhoseresponse.erroris:POST /v1/responses
curl ... /v1/responses --data-binary @responses.json400:Other-provider 4xx fallbacks on streamed /v1/messages and /v1/chat/completions (regression guard)
curl ... /v1/messages --data-binary @gem_temp_messages_stream.json(Gemini rejectstemperature: 7with a 400,fallbackspoints atgem-good)400, Gemini's own error and no fallback, the same body as the merge basecurl ... /v1/chat/completions --data-binary @gem_temp_chat_stream.json200, every chunk carries"model":"gem-good"and the textHi there!curl ... /v1/messages --data-binary @gem_404_messages_stream.json(the model does not exist,fallbackspoints atgem-good)404, Gemini's own error and no fallback, the same body as the merge baseControl, a small streamed /v1/messages call
curl ... /v1/messages --data-binary @small_messages_stream.json200,message_start,content_block_start, twocontent_block_delta,content_block_stop,message_deltawith"stop_reason": "end_turn",message_stop; the text isHi thereObservations from the run, both legs unless noted:
/v1/messagesnever falls back on a Gemini 400 or 404, pre-existing on main/v1/chat/completionsdoes fall back on that same Gemini 400/v1/responsesoverflow is 200 withresponse.failed, pre-existing"type": null, pre-existing/v1/messagesoverflow is an HTTP 400 JSON body before any stream frame (the first after run answered 200 with anapi_errorframe), and since 027f769e.responseon the non-streamed routes carries the provider's own 400 bodyAudit round 3 of 3 (/audit)
Verdict: PASS. Every inventory row is VERIFIED by a checked-in
tests/integration/cell or a live cell, and the two decisions round 1 left open (D1 and D2 below) are closed at this tipRounds 1 and 2 ran at 6f994f0 and 1c2c8f3 with the same selection; round 1's matrix and its two decisions are in this PR's history. This round is the one that counts, at the tip
Legs. Merge base
249add6f0dand head027f769c64, each a 2-worker proxy fromtests/integration/_support/proxy.pyontests/integration/proxy_config.yamlwith its own Postgres database, one shared Redis and the shared scripted upstream; the fallback cells boot their own 2-worker proxies from the same commit. The base leg runs the head's four test files on the base's product code, so a cell red there is a behavior the PR changes. Selection:tests/integration/providers/test_bedrock_mantle_context_overflow_wire.py,tests/integration/providers/test_bedrock_mantle_context_overflow_fallbacks_wire.py,tests/integration/providers/test_bedrock_mantle_wire.py, run throughtests/integration/run.py providers --seed 4106601 --order-seed 0. Base runbase-249add6f0d: 53 collected, 30 red, 23 green. Head run 1head-027f769c64-1: 53 collected, 53 green. Head run 2head-027f769c64-2, same seeds on a freshly booted head proxy: 53 collected, 53 green, identical selection, no skips or retries. Controlctl-027f769c64: the 3 pre-existing Mantle cells, 3 green. The cells the fix changes are the 30 red on the base; the 23 green on both legs are the cells the fix must not touchShared-rig cells,
tests/integration/providers/test_bedrock_mantle_context_overflow_wire.py, Mantle deploymentbedrock_mantle/openai.gpt-5.6-lunaat a wire peer that answers/openai/v1/responses; "generic" isprompt is too long: your prompt exceeds the model's context window, "raw" is the provider'scontext_length_exceededJSON, IRE isinvalid_request_errormessage_startthen an error frame, rawBUG:skip, now a live cell)message_startthen an error frame, rawinvalid_prompterror: nullopenai/deployment overflowopenai/deployment overflowopenai/deployment overflowCode-key note (R25, R25-quote). 027f769 matches
context_length_exceededonly as the envelope'scodekey, in its plain, Python-repr and escaped-JSON spellings (the unit testtest_bedrock_mantle_overflow_code_maps_in_every_serialization_of_the_envelopepins all three), so a text/plain body or a JSON message that merely names the code stays a plain 400 with the upstream text on both legs. Round 1's R25 expected the generic message for the text/plain body; that cell was renamed and its expectation flipped with the fix, which is the behavior a Mantle validation error quoting the code needsFallback cells,
tests/integration/providers/test_bedrock_mantle_context_overflow_fallbacks_wire.py, two owned 2-worker proxies per leg:cwfcarries onlycontext_window_fallbacks: [{mantle: [fallback]}],fbonlyfallbacks: [{mantle: [fallback]}]plus a/mantle-passthroughpass-through to the Mantle peer; the fallback deployment is anopenai/peer that answersfallback answeredinvalid_promptreplacing_workerR40 to R42 and R43 together are the fallback rule at the tip: a deployment whose
router_settingscarry only plainfallbacksstill takes them for the overflow on every non-streamed route, and no streamed route takes any fallback. The Medium caveat is the third configuration, plainfallbacksnext to acontext_window_fallbackslist that has no entry for the Mantle group, which the livefb_chatleg in the blast radius drivesExisting cells,
tests/integration/providers/test_bedrock_mantle_wire.py:test_bedrock_mantle_context_overflow_returns_400_saying_prompt_is_too_long,test_chat_completions_bridge_signs_mantle_responses_request_with_deployment_aws_keys,test_bedrock_mantle_messages_stream_relays_anthropic_sse_instead_of_failing_on_event_stream_decode: green on the control leg (R51 to R53)Live cells, the real Mantle deployment
bedrock_mantle/openai.gpt-5.6-lunabehind the head027f769c64and base249add6f0dproxies of the blast radius run, artifacts under itslegs/<side>/Not separate rows, and why: the provider pass-through routes (
/bedrock/...,/anthropic/...,/vertex_ai/...,/v1beta/...) forward the provider's own frames and never reach_error_status_and_message, whose only caller is the/v1/messagesadapter's mid-stream error frame (R48 and R49 cover the pass-through shape for the Mantle target); the one regex the diff adds,_BEDROCK_MANTLE_CONTEXT_LENGTH_EXCEEDED_CODE_PATTERN, is three literal tokens joined by\s*with no nested quantifier, so its cost is linear in the body and R26's 5 KB message is its timing row; the diff touches no hook, callback, metric or logging payload, so no response-cache twin rowsDecisions from round 1, both closed at this tip, one root cause then: on streamed
/v1/messagesa provider failure mapped before the first content chunk became a 200invalid_request_errorframe insideasync_anthropic_sse_wrapper, which the router declined to retry and the proxy never loggedfailurerow on both legs; the round-1 skip is the live cell R14-log, and the blast radius spend rows show the same on the real deploymentcontext_window_fallbacksfor aContextWindowExceededErrorwhen any such list is present and takes plainfallbacksotherwise (R40 to R42), the rule for every provider's overflow; R43 pins that the streamed path takes neither, and the configuration that loses a fallback it had on the base is the Medium caveat with its one-line remedyType
🐛 Bug Fix
Caveats (if any)
Medium
context_window_fallbackslist inrouter_settings, a Mantle overflow no longer takes the deployment's plainfallbacksentrycontext_window_fallbacksfor aContextWindowExceededErrorwhen any such list is present (router.py7747 to 7778), the rule for every provider's overflow, and raises when the group has no entry there. On the merge base the plain entry answeredfb_chatwith 200 from the fallback model in 27.0 s because the error was a plainBadRequestError; at the tip it answers 400No fallback was attemptedin 3.0 s. A deployment with plainfallbacksalone keeps them on every non-streamed route (audit R40 to R42). The context-window class is what this change exists to give the overflow, so the gate is the requested behavior. Remedy: add the Mantle group tocontext_window_fallbacks. Changing the gate would change every provider, out of scope here; named in the TLDRLow
Input length (N) exceeds model's maximum context length (M), a third wording that neither Mantle pattern nor the generic overflow substrings matchbedrock_mantle/openai.gpt-oss-120bwith a 400k-token prompt raises a plainBadRequestError400 non-streamed andMidStreamFallbackErrorwrappingServiceUnavailableError503 streamed, the same as the merge base. An internal follow-up ticket tracks it_extract_error_from_chunk) forwards only an in-stream chunk'serror.messageand assigns 500 when itscodeis a string, so a code-key envelope arriving in-stream on a chat-mode model would reach the new pattern without its keyopenai.gpt-5.6-lunadeployment, whose envelope the R1 to R25 cells cover; the other deployments seen (google.gemma-4-e2bin chat mode,openai.gpt-5.6-sol) emit the token-count form, which the chat-mode stream path maps at the tip to a 400ContextWindowExceededError(thegemma_chat_streamleg), and gpt-oss emit the wording above_error_status_and_messageon the Anthropic adapter's mid-stream frame to pass every provider status through, while this change's test pins other 4xx to 500api_errorexception_mapping_utils.pyis unchanged,router.pygains 14 lines outside the overflow gate (feat: forward-port dd user_email span tag and model hub description from internal staging #42749, feat(sdk): add litellm.systemone and trim litellm.decisions to the OpenAI shape #45846) andbedrock_mantle/chat/transformation.pygainsmax_completion_tokensfor gpt-5.6 and gpt-6 chat (fix(bedrock_mantle): send max_completion_tokens for gpt-5.6 and gpt-6 chat completions #43982), none on the overflow path; the audit's base cells ran at 249add6"code": "context_length_exceeded"anywhere in the error text, a nested or escaped envelope includedBedrockException - ...wrappers, and a 400 whose message merely quotes the code stays a plainBadRequestError(R25-quote)/v1/chat/completionsand/v1/messagesraise before any content, streamed/v1/responsesanswers 200 withresponse.failed(audit R37 to R39, R43 to R46)/v1/responsesoverflow still lands asresponse.failedinside HTTP 200context_length_exceededtext in the message now getprompt is too longContextWindowExceededErroris the class clients key on, and since 027f769e.responsestill carries the provider's own 400 bodymessages.create(stream=True)ends aftermessage_startwith no error on a streamed overflowinvalid_request_errorevent. An internal follow-up ticket tracks itresp_idPOST /model/newran, twice in audit round 1 and never in the six later runsBlast radius (/live-pr-risk)
Round 4 ran at the tip 027f769 (round 3's tip 1c2c8f3 merged with origin/main 249add6 as 5aa1471, plus the round-4 fix below) against a merge-base proxy on 249add6, the current merge base, so the tip already carries main and no separate merged tree was needed. Each side is one proxy with two uvicorn workers on its own native Postgres, the same config and the same scenario order as round 2's wave 4 (plain
fallbacksandcontext_window_fallbackslists next to each other, Gemini as the fallback target, the Responses-modeopenai.gpt-5.6-lunaand the chat-modegoogle.gemma-4-e2b), with a forwarding recorder in front of Mantle on both the bare host and the/openai/v1pathVerdict first. The PR does what it says on both Mantle models on every route, round 2's two findings stay closed (one by the status fix, one by the router convention named in the Caveats), and round 4's fix restores the provider body on the mapped error without moving any other leg
Breaking. With a
context_window_fallbackslist inrouter_settings, a Mantle overflow no longer takes the deployment's plainfallbacks: the base answeredfb_chatwith 200 fromgemini-3.8-flashin 27.0 s, and the tip answers 400litellm.ContextWindowExceededError ... No fallback was attempted.in 3.0 s. The graph edge is the router's fallback gate (router.py7747 to 7778), which on aContextWindowExceededErrorwithcontext_window_fallbacksset looks up only that list and raises the original exception when the group has no entry, the standing rule for every provider'sContextWindowExceededErrorrather than Mantle code. Shipped as the Medium caveat below and named in the TLDR's intentional-change line, the remedy being an entry for the Mantle group incontext_window_fallbacksFixed in 1c2c8f3, kept at the tip. Round 2 found the chat-mode streamed
/v1/messagesoverflow answering HTTP 500api_error, because the newContextWindowExceededErrorinherited the 500 the OpenAI chunk parser gives an in-stream error event. Observed at the tip:gemma_messages_streamanswers HTTP 400 JSON{"type":"error","error":{"type":"invalid_request_error","message":"litellm.ContextWindowExceededError: litellm.BadRequestError: prompt is too long: 229378 tokens > 131072 maximum"}}after one upstream attempt in 2.2 s (the base's 200 plusevent: errorof typeapi_errorcame after three attempts in 8.7 s),gemma_chat_streamanswers 200 with an empty first delta and an error chunk carrying"code": "400"(the base"503"), and through the Anthropic SDK (litellm.anthropic_interface.messages, async client) the error event aftermessage_startisinvalid_request_errorFixed in 027f769. Devin Review found that 1c2c8f3 built the mapped error on a bodiless synthesized 400, so an SDK caller reading
e.response.json()lost the provider's envelope the base still carried. The mapper now reuses the provider's own 400 response object (status, headers and body intact) and synthesizes a 400 with the original headers and request only when the original status is not 400 (the in-stream chunk-parser error, which has no body on either side). It also matchescontext_length_exceededonly as the envelope'scodekey (plain, Python-repr and escaped-JSON serializations), so a Mantle 400 whose message merely quotes the words stays a plainBadRequestErrorwith the upstream message. Observed at the tip: every non-streamed overflow leg answers the same 400 as round 3 with the same spend row, and the 16 legs match round 3's tip leg for legBackward incompatible. The error class on every Mantle overflow moves from
BadRequestErrorto its subclassContextWindowExceededError, soexcept BadRequestErrorstill catches it and the non-streamed status stays 400, and the spend row'serror_classmoves with it (every overflow leg at the tip wrote afailurerow withContextWindowExceededError). The message loses the provider text,BedrockException - {"error":{"code":"context_length_exceeded",...becomesprompt is too long: your prompt exceeds the model's context windowon thecontext_length_exceededenvelope andprompt is too long: 229378 tokens > 131072 maximumon the token-count one, whilee.responsekeeps the provider's 400 body on the non-streamed routes since 027f769.context_window_fallbacksnewly fire for the overflow on the non-streamed routes (cwf_chat200 fromgemini-3.8-flashin 25.0 s at the tip against 400 in 5.5 s on the base) and on none of the streamed ones (cwf_messages_stream400 on both sides), so a client that sent an oversized streamed request and handled the 400 still gets the 400, and a client that sent it non-streamed gets a slower answer from a different modelRegression risk. Pre-existing on both sides and outside the PR, named so nobody reads them as PR findings: no streamed route falls back on an overflow, plain or context-window (streamed chat and
/v1/messagesraise before content, streamed/v1/responsesanswers 200 withresponse.failed); the sync Anthropic SDKcreate(stream=True)ends silently aftermessage_starton both sides; the two streamed chat-mode failure rows carry the same estimated 0.0116 spend on the base (its 503 rows) and the tip; a success row for a Responses-bridged call is keyed by the upstreamresp_id (SLP-882, filed by round 2). Every other construction ofContextWindowExceededErrorinexception_mapping_utils.py(twelve other providers) still passes the original exception's response through, so an in-stream overflow mapped there would carry the same inherited status this PR fixed for Mantle, untouched here since none of those paths is in the diffDependency graph. Re-walked at the tip.
_bedrock_mantle_context_window_responseis called only from the Mantle branch of_map_bedrock_exception, whichexception_typereaches undercustom_llm_provider == "bedrock_mantle", and what it returns is read byopenai.APIStatusError.__init__(status, headers, body) and by nothing else;_BEDROCK_MANTLE_CONTEXT_LENGTH_EXCEEDED_CODE_PATTERNis read only by_get_bedrock_mantle_context_window_message, whose callers are unchanged since round 2, and_error_status_and_messagein the Anthropic adapter keeps the callers named there. The outbound wire is identical at the tip and on the base for every leg but one: 15 recorded requests at the tip against 17 on the base, the same four header keys (accept, authorization, content-type, user-agent), the same paths (/openai/v1/responsesfor the Responses-mode legs,/chat/completionson the/openai/v1recorder for the chat-mode legs), the same body sizes request for request, and the streamed chat-mode/v1/messagesoverflow at one attempt where the base took three. Verified live at the tip: the six overflow routes on the Responses-mode model, the five on the chat-mode model (three through the recorder, the Responses stream direct), the plain and context-window fallback legs, the Anthropic SDK bridge in both clients, the two control calls, and/health; the base rows ran on 249add6 on the same config. Legacy suites asserting on context window mapping passed on round 2's tip, and the product diff since then is the status helper, the response reuse and the code-key match above, each covered by the unit tests the fix commits addNot verified.
openai.gpt-oss-120b, the chat-mode model the PR's own wire tests name, answers 404 on this account on every route, so the chat-mode legs ran ongoogle.gemma-4-e2b. The chat-mode Responses stream ran only direct, not through a recorder. Codex and the OpenAI SDK against streamed/v1/responseswere not driven (the shape there is unchanged and the curl leg answered 200 withresponse.failedon every side)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
027f769 passes /live-pr-risk
027f769 passes /audit
Note
Medium Risk
Changes error class and streamed /v1/messages overflow shape (500 → 400 SSE), which stops plain fallbacks on that path and skips spend logging for streamed messages overflow; routing and observability for Mantle overflow are affected beyond the mapping fix.
Overview
Bedrock Mantle’s OpenAI-shaped
context_length_exceedederrors (400 JSON or streamedresponse.failedwithout an error type) now map toContextWindowExceededErrorwith aprompt is too longmessage, including when the oldinvalid_request_error/validation_errorgate would have skipped streamed events. Token-countvalidation_errorbodies still get the numericN tokens > M maximumwording.On streamed
/v1/messages,ContextWindowExceededErroris surfaced as a 400invalid_request_errorSSE error event (not a 500api_error), so clients like Claude Code can compact; other provider 4xx mid-stream errors stay 500 so plainfallbacksstill retry.context_window_fallbacksapply on non-streamed chat/messages/responses; streamed overflow paths do not fall back (documented behavior change vs accidental 500 fallback).Large wire/integration and unit test coverage documents mapping, fallback knobs, pass-through verbatim relay, and edge cases.
Reviewed by Cursor Bugbot for commit 750ba1e. Bugbot is set up for automated code reviews on this repo. Configure here.