litellm: carry prior-turn assistant reasoning to OpenRouter (Patch 10) - #3698
Conversation
…uter On egg's primary route (Claude Code -> gateway -> LiteLLM /v1/messages -> OpenRouter), assistant reasoning from prior turns was never sent back to the model. LiteLLM's Anthropic adapter converts incoming `thinking` content blocks into `assistant_message["thinking_blocks"]`, but nothing on the OpenRouter request path consumes that field: stock openrouter/chat/transformation.py names reasoning only on the response side, and `reasoning_details` appears nowhere under llms/ or litellm_core_utils/. The parent `transform_request` puts `messages` straight into the body, so `thinking_blocks` reached OpenRouter as a field no one reads and every historical assistant turn arrived with no reasoning. For a model whose chat template re-renders prior thinking that is a malformed history, not a lost optimisation: Poolside Laguna renders `'<think>' + message.reasoning|message.reasoning_content + '</think>'` per previous assistant turn, so each one became a literal empty `<think></think>`, which Poolside's model card warns degrades follow-up behaviour. The gap is upstream's and predates every egg and fork change; jwbron/litellm#8 touches only `get_supported_openai_params`, which acts on `optional_params` and cannot reach a message field. Patch 10 maps the blocks onto `reasoning_content` in `OpenrouterConfig.transform_request` and removes `thinking_blocks`. The logic lives in a fourth staged module (`openrouter_reasoning_roundtrip.py` -> `llms/openrouter/_egg_reasoning_roundtrip.py`), following the same convention as Modules 1-3 so it stays lintable and unit-testable here. Multiple blocks concatenate in order; `redacted_thinking` carries no plaintext and contributes nothing; a whitespace-only result emits no field rather than re-creating the empty `<think></think>`; Anthropic `signature` values are not forwarded; assistant messages only; fail soft, so a block that cannot be parsed leaves its message untouched. Note the direction: this is request-path (client -> provider). Patches 4, 5a and 5b are response-path (provider -> client). Adjacent, not the same. Verified by applying the full script to a real stock litellm 1.86.2 install (all 10 patches and 4 modules apply, and re-applying is a no-op) and replaying a real Claude Code /v1/messages body through it with the provider endpoint captured: before, the outgoing assistant message was `['role', 'thinking_blocks', 'tool_calls']` with no reasoning field; after, `['reasoning_content', 'role', 'tool_calls']`. Mirrors jwbron/litellm#11.
There was a problem hiding this comment.
Review — Patch 10 (assistant reasoning round-trip)
I verified the core mechanism against real stock litellm==1.86.2 rather than the fixtures, and it holds up:
- The needle matches
llms/openrouter/chat/transformation.pyexactly once in stock v1.86.2, and the patched source parses (ast.parse)._supports_cache_control_in_contentappears 3× and_move_cache_control_to_content2× in that file, so the extended needle spanning theextra_bodypop is genuinely necessary — the anchoring rationale is correct. - The
messagesrebind is consumed:super().transform_request(model, messages, ...)attransformation.py:166uses it, andOpenAIGPTConfig._transform_messages(the only later message rewrite) touches user-role content lists only, soreasoning_contentsurvives to the body. Nothing else underllms/orlitellm_core_utils/strips it —strip_none_values_from_messageis Mistral-only. - The async path reaches the patch:
OpenAIGPTConfig.async_transform_requestdelegates toself.transform_request(...)when_is_base_classis False, which__init__sets forOpenrouterConfig. - OpenRouter does document
reasoning_contentas a request-side alias formessage.reasoning("You can also usereasoning_contentas an alias - it functions identically toreasoning"), so the target field is right.
The diagnosis and the anchoring are sound. One blocking defect in the new module, plus advisories.
Blocking
1. thinking_blocks: None is the common case, and it lands in the fail-soft branch — the field is never stripped and the exception path becomes the hot path
config/litellm/openrouter_reasoning_roundtrip.py:100-107
The module guards on key presence:
if message.get("role") != "assistant" or _SOURCE_FIELD not in message:
out.append(message)
continue
text = _extract_reasoning_text(message.get(_SOURCE_FIELD))But the adapter you cite as the producer sets the key unconditionally, including when there is no reasoning. llms/anthropic/experimental_pass_through/adapters/transformation.py:652-658 (v1.86.2):
assistant_message = ChatCompletionAssistantMessage(
role="assistant",
content=assistant_content,
thinking_blocks=(thinking_blocks if len(thinking_blocks) > 0 else None),
)ChatCompletionAssistantMessage is a plain TypedDict (types/llms/openai.py:745, deriving from OpenAIChatCompletionAssistantMessage(TypedDict, total=False) at :724), so calling it is just dict(**kwargs) — the key is present with value None, not absent.
So _SOURCE_FIELD not in message is False, _extract_reasoning_text(None) raises TypeError, the broad except Exception swallows it, and the original message is appended.
Reproduced against the committed module:
$ python3 -c "...map_thinking_blocks_to_reasoning_content([{'role':'assistant','content':'hi','thinking_blocks':None}])"
no-thinking assistant -> {'role': 'assistant', 'content': 'hi', 'thinking_blocks': None}
thinking_blocks still present: True
Two consequences:
- The documented contract is false in the dominant case.
patch_litellm_cache.py:180-182states "thinking_blocksis removed either way so no unknown field is transmitted", and the module docstring says "drop that field so no unknown key is transmitted". Every assistant turn that emitted no reasoning — the majority of turns on any route, and all turns onpoolside/laguna-s-2.1, which your own precondition probe found returns no reasoning at all — still ships"thinking_blocks": nullto OpenRouter. The PR body's before/after table only exercises a message that had blocks, which is why this wasn't caught. - The last-resort branch is the routine branch.
except Exceptionis documented as "a request must survive a bad block" — a shape you did not anticipate. It now fires on essentially every assistant message in every request. That destroys the branch's diagnostic value: if anyone later adds a log line or counter there (as all three sibling modules do), it fires on ~100% of traffic and the genuinely-malformed case is unfindable in the noise.
Fix is one line — treat the adapter's own sentinel as "nothing to map" rather than "unparseable", so the key is stripped cleanly:
blocks = message.get(_SOURCE_FIELD)
if blocks is None:
blocks = [] # the adapter's "no thinking" sentinel, not a bad shape
text = _extract_reasoning_text(blocks)or equivalently handle None inside _extract_reasoning_text before the isinstance(blocks, list) check.
Test gap that points straight at this. test_malformed_blocks_never_raise parametrizes over "not-a-list", {"type": "thinking"}, 17, [None], ["bare string"], [{}] — every shape except bare None, the one value the production upstream actually emits. And test_assistant_without_thinking_blocks_is_returned_as_is asserts on {"role": "assistant", "content": "plain"} with the key absent, a shape the /v1/messages adapter never produces. Please add None to the parametrize list with the stripping assertion, not the untouched one.
Non-blocking
2. Signature discard + concatenation is unsafe for openrouter/anthropic/* and openrouter/google/* routes
The mapping is unconditional across every OpenRouter slug. For a Claude model routed through OpenRouter, the thinking signature is not decoration — it is what the Anthropic upstream verifies when prior thinking is replayed on a tool-calling turn, and OpenRouter's own docs place signatures inside reasoning_details, telling callers to "Pass back unmodified" and warning that "you cannot rearrange or modify the sequence of these blocks."
This patch does the opposite for those routes: drops signature, and flattens N ordered blocks into one string. That the shipped config/litellm-models.template.yaml only pins openrouter/qwen/qwen3-max today doesn't settle it — the template is operator-edited, and CacheControlSupportedModels in the very file being patched already carries CLAUDE and GEMINI, so Claude-via-OpenRouter is an anticipated route here.
I could not confirm a concrete failure (I have no Claude-via-OpenRouter route to probe), so this is advisory, not blocking. Suggest either gating the mapping to non-Anthropic slugs, or emitting reasoning_details (preserving signature and block boundaries) when any block carries a non-empty signature and falling back to the plain string otherwise. Worth at minimum a docstring note that Anthropic-via-OpenRouter is out of scope.
3. The module is completely silent, unlike all three of its siblings
Every other staged module treats silence as the bug and logs through verbose_logger with dedup — openrouter_capabilities._log / _log_fetch_failure (:88, :287), drop_params_visibility._log_warning (:39), and anthropic_thinking_policy (:82, which explicitly cross-references the convention). Patch 8 exists entirely because a silent drop was unfindable.
Module 4 has no logging at all, and the injected call site is except Exception: pass with nothing emitted. If _egg_reasoning_roundtrip ever fails to import, the feature is an invisible no-op with zero signal in the pod stream — and the only way to notice is a model quietly regressing. Build-time install is fail-loud so the risk is low, but a one-shot verbose_logger.warning on import failure would match the house style and costs nothing. This matters more once #1 is fixed, since the swallow branch will then genuinely mean "something is wrong."
4. Blocks concatenate with no separator
_extract_reasoning_text does "".join(parts). Two adjacent thinking blocks — which the adapter produces whenever Claude Code interleaves thinking with text/tool_use in one assistant turn — join as ...end of firstbeginning of second, running words together mid-sentence. The tests pass only because the fixtures carry explicit trailing spaces (_thinking("first ")). "\n" (or "\n\n") would be safer and is what a <think> re-render expects.
5. A pre-existing reasoning_content is silently overwritten, but only sometimes
If a message arrives with both thinking_blocks and reasoning_content — which litellm's own response objects carry (convert_dict_to_response.py:594-611, gpt_transformation.py:575-583), so any client echoing a litellm assistant message back will send both — the mapping clobbers reasoning_content when the blocks yield text, but preserves it when they yield only whitespace or redacted blocks. Two different rules for the same field. Usually the two agree in content so the impact is nil, but the asymmetry is unintentional; pick one and document it.
6. Stale docs — four places
docs/guides/per-agent-models.md:596— "bakes in nine patches closing those gaps"; it's ten now.docs/development/STRUCTURE.md:545-549— theconfig/litellm/tree lists each staged module with its patch number (openrouter_capabilities.py # Patch 7,drop_params_visibility.py # Patch 8,anthropic_thinking_policy.py # Patch 9).openrouter_reasoning_roundtrip.pyis missing, and line 545's summary ofpatch_litellm_cache.pydoesn't mention the round-trip.tests/config/test_litellm_runtime_modules.py:1-5— docstring still says "the three modules" and names only three; there are four.- Same file,
test_module_imports_without_litellm(~:450) asserts on the three existing modules and wasn't extended. The newtest_module_imports_without_litellm_installedis a textual grep forimport litellmin the source, which is strictly weaker than the existing test's actual import under a purgedsys.modules. Add the fourth module to the existing test rather than substituting the grep.
7. No runtime escape hatch
Patches 7 and 9 are both revertable without an image rebuild via commented-out env vars on k8s/base/litellm-deployment.yaml:102-122, and per-agent-models.md documents them as the knobs controlling reasoning on OpenRouter routes. Patch 10 changes the outgoing request body on every OpenRouter call and its correctness depends on provider-side behaviour verified against two models. Given the precedent for the reasoning-related patches specifically, a LITELLM_OPENROUTER_REASONING_ROUNDTRIP=0 guard would let an operator back it out of a live cluster. Judgment call — patches 1-6 and 8 have none either — but this one is closer to 7/9 in blast radius than to the cache patches.
8. Needle fixtures remain self-referential
_build_fixture_root constructs each fixture by concatenating the needles themselves, so test_each_needle_occurs_once_in_its_fixture and friends cannot detect a needle that doesn't match real upstream source. Pre-existing for all ten patches and mitigated by the fail-loud build, so not something to fix here — but it does mean the "confirmed to match real stock v1.86.2 exactly once" claim rests on a manual check with no automated guard. I re-ran that check by hand against the v1.86.2 tag and it holds (count 1, patched source parses); a checked-in trimmed excerpt of the real upstream region would make it durable.
Verdict: request changes on #1. It's a two-line fix, but the module ships a documented guarantee it does not honour in the majority case, and it routes ordinary traffic through the branch reserved for corruption. Everything else above is advisory.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Review feedback on #3698. The adapter sets thinking_blocks=None (key present, TypedDict) on every assistant turn that produced no reasoning, so the key-presence guard sent that value into _extract_reasoning_text, which raised, which routed the dominant message shape through the fail-soft branch: "thinking_blocks": null still reached OpenRouter, and the branch reserved for corruption became the hot path. It is now read as the sentinel it is. Also: decline anthropic/* and google/* slugs, whose upstream re-verifies the block signature this plain-string form cannot carry; join blocks on a newline rather than running them together; never overwrite a reasoning_content the caller already set; log an unmappable shape and an unavailable module once each; add LITELLM_OPENROUTER_REASONING_ROUNDTRIP=0 as a runtime escape hatch, as for Patches 7 and 9; refresh the four stale doc/test references to the patch and module counts.
Review response — all 8 itemsThanks for verifying the mechanism against real stock 1.86.2 rather than the fixtures; #1 is exactly right and was the kind of defect the fixtures could never have shown. Pushed as 1.
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review — Patch 10, delta 1b36232..97feaff
The blocking item is genuinely fixed, not papered over. Verified against real stock litellm==1.86.2 source rather than the repo's fixtures.
#1 — thinking_blocks: None sentinel. Confirmed the production shape at llms/anthropic/experimental_pass_through/adapters/transformation.py:653-663 in v1.86.2: ChatCompletionAssistantMessage(..., thinking_blocks=(thinking_blocks if len(thinking_blocks) > 0 else None)), with the re-assignment on :662 guarded by len(thinking_blocks) > 0 — so the key is present-and-None on every assistant turn that produced no reasoning, exactly as diagnosed. openrouter_reasoning_roundtrip.py:258-266 now reads it as the sentinel, and the message flows through the normal path:
sentinel -> [{'role': 'assistant', 'content': 'hi'}] # thinking_blocks stripped, no field emitted
Both consequences are closed: "thinking_blocks": null no longer reaches OpenRouter on the dominant message shape, and the except Exception branch stays cold — test_none_sentinel_does_not_fire_the_fail_soft_diagnostic pins the second property, which is the one that would decay silently.
Patch application still holds after the replacement changed. Applied the current PATCHES entry to the real v1.86.2 llms/openrouter/chat/transformation.py: needle count 1, patched source ast.parses, the injected call lands inside transform_request (where model is a parameter at :150, so _egg_map_reasoning(messages, model) resolves), and _egg_map_reasoning appears nowhere after def transform_response.
The injected import-failure path works, not just its string literals. Executed the injected block against a fake litellm.llms.openrouter with the module absent: 4 calls, exactly 1 verbose_logger.warning, _egg_reasoning_roundtrip_warned set, messages returned unmodified. The nested try cannot break a request.
Module behaviour across the rest of the delta, exercised directly: blocks join on \n; an anthropic/google slug returns the message byte-identical; LITELLM_OPENROUTER_REASONING_ROUNDTRIP=0 returns stock; a caller-set reasoning_content survives while the blank one does not block the mapping; input is not mutated.
Two things I checked because the delta could have silently no-op'd the feature:
- The adapter never sets
reasoning_contenton the assistant messages it builds (onlyrole/content/tool_calls/thinking_blocks), so the new "caller's field wins" rule cannot short-circuit the mapping on egg's own/v1/messagespath. - No slug in
config/litellm-models.template.yaml(openrouter/qwen/qwen3-max) matches_SIGNATURE_VERIFYING_MARKERS, so the new decline gate does not disable the patch on a shipped route.
Removed-behaviour audit. Dropping test_module_imports_without_litellm_installed was required, not a loss — the module now carries a deferred from litellm._logging import ... inside _log, which that grep would have failed on. The replacement is strictly stronger: openrouter_reasoning_roundtrip was added to test_module_imports_without_litellm (:459), a real import under a purged sys.modules.
tests/config/test_litellm_runtime_modules.py + tests/config/test_patch_litellm_cache.py: 141 passed. Docs delta is consistent — three patches, five env vars, matching the three commented blocks on k8s/base/litellm-deployment.yaml.
No blocking issues in the delta.
— Authored by egg
|
egg review completed. View run logs 3 previous review(s) hidden. |
Merge order with #3699: land this one first#3699 (the 1.94.0 bump) is branched from #3695, not from this PR, so Patch 10 is not in its tree. Proposed order: this PR, then #3695, then #3699. Reasons:
The conflict is wider than the patch scriptSeven files are touched by both: A three-way merge of What #3699 has to redo, and what is already verifiedRe-verified against the actual merged tree rather than a stock tag (see #3699 for the same check
So what is left for #3699 is bookkeeping, not investigation:
|
…cts in patch_litellm_cache.py, Dockerfile, STRUCTURE.md, per-agent-models.md, and the two litellm test modules main landed its own Patch 10 (OpenrouterConfig.transform_request assistant reasoning round-trip, #3698) while this branch independently added Patch 10 (streamed cost preservation) and Patch 11 (openrouter live pricing). Every conflict is a numbering collision on otherwise-additive work: the two sides patch different functions in different files with disjoint needles. Resolution keeps main's already-landed patch at 10 and renumbers this branch's to 11 (streamed cost preservation) and 12 (openrouter live pricing). Totals become 12 patches / 5 modules. Renumbering was carried through to every downstream reference, including files that had no conflict: cost_callback.py, stream_cost_preservation.py, and k8s/base/litellm-deployment.yaml. The runtime module test's py311 parse-coverage tuple gained main's new openrouter_reasoning_roundtrip.py.
…lm-bump: resolve conflicts in patch_litellm_cache.py, its tests, Dockerfile, STRUCTURE.md, per-agent-models.md, litellm-deployment.yaml, stream_cost_preservation.py The conflict is renumbering, not semantics. The base branch carries 12 patches; this branch retired four that 1.94.0 absorbed and added one, leaving 9. The base independently added one genuinely new patch (the prior-turn assistant reasoning round-trip, from #3698). Target: 10. Every conflict is a patch number that moved. The one substantive decision was the round-trip patch, which was authored against 1.86.2: rather than assume it survived the bump, its needle was checked against the real 1.94.0 wheel. It matches, once, unmodified — and the anchoring comment inherited from the base was stale, since _supports_cache_control_in_content appears three times in 1.94.0, not twice. The comment is corrected here. The absorbed round-trip tests are converted off the number-prefix lookup (startswith 'Patch 10/') onto _patch_by_description(), the renumbering-resilient helper this PR introduced after a prefix lookup silently re-bound to a different patch and produced a false pass. One duplicate test the merge produced — the base's test_patch11_sets_cost_after_the_rebuild_not_before, which this branch had already renamed to test_cost_details_carry_runs_after_the_rebuild_not_before — is dropped. Three stale patch numbers that git auto-merged without flagging a conflict were found by sweeping the resolved files for patch references and corrected: litellm-deployment.yaml:134, and two in per-agent-models.md. Verified: patch script applies all 10 patches plus 5 modules to a real litellm==1.94.0 wheel, second pass fully idempotent, all patched files compile, Patch 7 confirmed by AST to land inside transform_request exactly once. 217 targeted tests pass; make lint clean.
Post-merge record: what this patch does, what it cannot do alone, and one sentence above that is now too strongLanding this as a comment because the body is history now. It also contains one claim that later The control, quoted rather than summarisedThree arms, identical history, tools present in every arm, {"role": "assistant", "content": null,
"tool_calls": [{"id": "call_1", "type": "function",
"function": {"name": "run_shell", "arguments": "{\"command\": \"ls /etc | wc -l\"}"}}]}The post-patch arm adds exactly one key, which is what this patch produces: "reasoning_content": "The user wants a count of files in /etc. I should run `ls /etc | wc -l` rather than listing everything, since only the count was asked for. After I get the number I will report it directly without re-running anything."The control arm adds that same string under
The control is the load-bearing cell: it is the field, not the extra context. The same result holds The field is honoured on the request pathVerified separately from the cells above: It still cannot fire on this traffic without a seedFour-turn sessions, two draws each:
Arm B is this patch with nothing to replay. So the original "inert on this route" claim was right The seed, and its limit, togetherThe seed is cheap: an opening turn with Do not take that half without this one. Replay-forward has since been measured at 3 of 6 Correction to the body aboveThe body says: "this PR is the half that keeps the seed alive once it exists." That is too strong. Re-verified against current
|
The defect
On egg's primary route (Claude Code -> gateway -> LiteLLM
/v1/messages-> OpenRouter),assistant reasoning from prior turns was never sent back to the model.
Verified against pristine
BerriAI/litellmat v1.86.2, the version the image pins:thinkingcontent blocks intoassistant_message["thinking_blocks"](
llms/anthropic/experimental_pass_through/adapters/transformation.py:662-663).llms/openrouter/chat/transformation.pyhas no request-path consumer for that field. Itsonly reasoning line is response-path (
reasoning->reasoning_contenton streaming deltas).reasoning_detailsappears nowhere underllms/orlitellm_core_utils/.OpenAIGPTConfig.transform_requestputsmessagesstraight into the request body, sothinking_blocksreached OpenRouter as a field no one reads and every historical assistant turnarrived with its reasoning missing.
For a model whose chat template re-renders prior thinking this is a malformed history rather than
a lost optimisation. Poolside Laguna renders
'<think>' + message.reasoning|message.reasoning_content + '</think>'for every previousassistant turn, so each one became a literal empty
<think></think>; Poolside's model card warnsthis degrades follow-up behaviour.
The gap is upstream's. It predates every egg and
jwbron/litellmchange. The fork's OpenRouterwork (jwbron/litellm#8) is confined to
get_supported_openai_params, which acts onoptional_paramsand cannot reach a message field.Precondition check
The fix is only worth making if Claude Code actually returns thinking blocks in the
/v1/messagesrequest body for a non-Anthropic model, so that was confirmed first, by capturing real request
bodies through a logging proxy in front of the LiteLLM proxy.
It does, under egg's configuration. A two-turn tool-calling exchange against
deepseek/deepseek-v4-proproduced a follow-up request whose assistant message carried['text', 'thinking', 'tool_use'], with the block shaped{"type": "thinking", "thinking": "...", "signature": ""}.Two findings worth recording:
poolside/laguna-s-2.1the blocks do not come back, and the reason is not what this PRoriginally said. An earlier version of this body said that route "returned no reasoning content
to begin with". The outcome was right and the cause was wrong, and the difference decides whether
this patch is worth landing. See the section below.
cllmlauncher is not representative of egg. It setsANTHROPIC_CUSTOM_MODEL_OPTION_SUPPORTED_CAPABILITIES="", which egg deliberately does not set(
docs/guides/per-agent-models.md:416-425). The precondition was therefore re-run underegg-equivalent environment before being accepted.
Laguna: the patch is load-bearing, and it still cannot fire today
This section supersedes the original claim that Laguna "returns no reasoning at all". It does
reason. What it does not do, on egg's traffic, is ever start.
The patch does real work, and the control is what proves it
Three arms, identical history,
max_tokens800, tools present in every arm. The only thing thatmoves is what the prior assistant message carries. Full message sequences are in the reproduction
record on #3595.
reasoning_tokensreasoning_content: <prior reasoning>contentThe control is the load-bearing cell. Identical text, identical token count, attached to a
different field: 4/4 against 0/4. It is specifically
reasoning_content, which is exactly what thispatch produces, and not extra context, that restores the channel. The same result holds when the
next turn also emits a tool call (0/3 against 3/3, both arms calling the tool in every draw), so it
sustains through an agent loop rather than only on final answer turns.
Traced end to end through a real patched v1.86.2: a reasoning-bearing response comes out of the
adapter as
['thinking', 'text', 'tool_use'], so Claude Code echoes a thinking block back, and thispatch maps it to
reasoning_contenton the next request. A response with no reasoning channel comesout as
['text', 'tool_use'], and there is nothing to map.Why it is nonetheless inert on egg's Laguna traffic right now
Because nothing seeds the channel. A tool-bearing opening turn on an errand-shaped prompt does not
reason, so there is no first reasoning to carry forward, and a patch that replays reasoning has
nothing to replay. Four-turn sessions, two draws each:
reasoning_tokensper turn[0,0,0,0][0,0,0,0][0,0,0,0][0,0,0,0][1601,746,309,70][1448,490,368,237][1198,0,0,0][1720,0,0,0]Arm B is this patch alone: worthless without a seed. Arm D is a seed alone: it fires once and is
gone by the very next turn. Arm C is both, and it is the only arm that works. Arm D is also exactly
the shape production shows, a session total of reasoning tokens against otherwise uniform per-call
zeros.
So the original "inert on this route" was right about the outcome and wrong about the cause. Not
"the model does not reason", but "nothing ever primes it to".
The seed is now known, and it is a per-request parameter
Run round-robin with cells interleaved, because the unprimed rate drifts over time and blocking the
cells would confound drift with cell. n=4 per cell,
max_tokens2500, fresh opening turn.reasoning_tokens[0,0,0,0]tool_choice: "none"+ errand[236,0,485,1135][0,527,696,522]tool_choice: "none"+ reasoning-shaped[1334,1584,1261,2105][246,638,821,105][1791,1646,1311,1647]Callable tools are the strong lever and
tool_choicedefeats them: 0/4 to 3/4 on a byte-identicalerrand prompt with only
tool_choicemoved. Prompt shape is the weaker lever and mostly drivesmagnitude.
Recipe: open every agent session with one turn that sets
tool_choice: "none"and isreasoning-shaped. 4/4 at 1261-2105 seed tokens, indistinguishable from dropping the tools array,
and it is a per-request parameter rather than a change to how egg assembles the tools array, which
makes it far cheaper to ship. That recipe is not in this PR; this PR is the half that keeps the seed
alive once it exists.
Acceptance signal, per turn type, untested
reasoning_tokensshould be non-zero insteadof uniformly zero.
whether reasoning survives an agent loop at all.
is a per-turn zero, not a lower mean.
Nothing in this PR measures any of that on a real pipeline. It needs a run on the patched image with
the seeding recipe applied.
What is untested, explicitly
(1601, 746, 309, 70), which extrapolates to exhaustion somewhere around turn 6 to 8. egg's sessions
run hundreds of calls. Four turns is not evidence about forty.
reasoning, so a single turn that happens not to reason may end the chain permanently. Untested, and
it is the difference between a fix and a fix that needs re-seeding.
inference, not measurement, and are marked as such on Make egg tolerant of weak-termination models: force the terminal action, suppress degenerate repeats, and check correspondence deterministically #3692 and Repetition-trapped agent is invisible to every progress detector: 94 min / 613 calls / 162M tokens, zero output #3595.
from 0/4 to 4/4 across time windows. Treat the recipe as a strong candidate, not a settled fix.
The change
Patch 10 maps assistant
thinking_blocksontoreasoning_contentinOpenrouterConfig.transform_requestand removesthinking_blocksso no unknown field istransmitted.
OpenRouter accepts
reasoning,reasoning_contentandreasoning_detailsinterchangeably on anassistant message and documents this for exactly this multi-turn tool-calling case; Poolside's
template reads
message.reasoning/message.reasoning_content. The plain string form is usedbecause
reasoning_detailsexists to carry encrypted or summarised blocks and the adapterproduces neither.
The logic lives in a fourth staged module (
config/litellm/openrouter_reasoning_roundtrip.py->
llms/openrouter/_egg_reasoning_roundtrip.py), following the same convention as Modules 1-3,so it stays lintable and unit-testable in this repo rather than living as a string literal. Module
labels renumbered
N/3->N/4and patch labelsN/9->N/10; the Dockerfile stages the newfile; the module docstring gains the tenth entry.
Edge cases, all covered by tests: multiple blocks concatenate in order;
redacted_thinkingcarriesopaque
dataand contributes nothing; a whitespace-only or empty result emits no field rather thanre-creating the empty
<think></think>;signatureis not forwarded; assistant messages only;input is not mutated; fail soft, so a block that cannot be parsed leaves its message exactly as it
arrived; idempotent.
Needle anchoring.
_supports_cache_control_in_contentand_move_cache_control_to_contentareeach named twice in 1.86.2, so neither call alone is unique. The needle spans the cache_control
pair and the following
extra_bodypop, a sequence that occurs only intransform_request.Confirmed to match real stock v1.86.2 exactly once, with a test that pins the anchoring against a
sibling-call-site fixture (same discipline as Patches 4 and 8).
Direction. This is request-path (client -> provider). Patches 4, 5a and 5b are response-path
(provider -> client). Adjacent, not the same thing; a test asserts this patch never touches
transform_response.Validation
Applied the full script to a real stock
litellm==1.86.2install: all 10 patches and 4 modulesapplied, and a second run reported all 15 already applied (idempotent).
Replayed a real captured Claude Code
/v1/messagesbody through that patched proxy with theprovider endpoint captured:
['role', 'thinking_blocks', 'tool_calls'], no reasoning field['reasoning_content', 'role', 'tool_calls'], nothinking_blocksLive two-turn tool-calling exchange against
deepseek/deepseek-v4-prothrough the patched proxycompleted normally. Per-turn growth of the assistant history on the follow-up turn: 118 tokens
before, 146 after (+28, 1.24x) — the restored reasoning, exactly. The magnitude scales with how
much the model reasons; these probe tasks were small, so this is a floor rather than a typical
figure.
Note on a measurement that looks contradictory: total prompt tokens for the whole replayed body
went down (1992 -> 1982). That is Patch 3's billing-header filter also being active in the
patched build, not Patch 10; the isolated figure is the assistant-turn delta above.
make lintpasses.make testhas 4 failures, all pre-existing: verified by re-running them atpristine
HEADwith these changes set aside, where 3 fail identically(
test_reap_stale_egg_images.py::TestReapScriptSafetyGuardx2,test_git_client.py::test_worktrees_parent_detected) and the 4th(
test_error_paths.py::test_session_expires_exactly_at_boundary) passes, i.e. a timing flake.None are in
tests/config/. The 114 tests intests/config/test_litellm_runtime_modules.pyandtests/config/test_patch_litellm_cache.pypass.Fork PR
jwbron/litellm#11 implements the same change as ordinary source (
llms/openrouter/reasoning.pyplus the
transform_requestcall), with 27 tests in the fork's own layout; its fulltests/test_litellm/llms/openrouter/suite passes (154 tests). The two implementations areAST-identical modulo docstrings and comments, checked mechanically, since the patch literal here is
what actually runs in production and divergence would be a bug. This PR does not make egg's
image build from the fork. A separate upstream
BerriAI/litellmPR is a follow-up.