Repository navigation
Conversation
Non-Anthropic reasoning models routed through OpenRouter return their reasoning as an opaque reasoning_details array (encrypted entries and signed texts included) under provider_specific_fields. LiteLlm flattened it into a plain reasoning_content string and never sent the array back, so the model lost its reasoning state across tool round trips. Capture the array into a thought part's thought_signature, the same slot that preserves Anthropic thinking signatures, and replay it unchanged as a top-level reasoning_details field on the assistant message. LiteLLM's OpenRouter adapter forwards that field to the provider as-is, and text-only reasoning keeps the existing reasoning_content replay. Streaming capture is not wired because LiteLLM drops reasoning_details deltas before they reach this adapter (BerriAI/litellm#48401). Fixes google#8109
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
9 tasks done
Author
|
Leaving a note for the reviewer: two of the red checks on this PR look stale rather than actionable.
|
4 tasks
Other providers reject the reasoning_details field, and OpenRouter requires the blocks to match the model's own outputs, so replaying the stored array regardless of the next request's route breaks sessions that move between models or providers. Record the producing model in the envelope and only replay while the outgoing request still targets an OpenRouter route (model prefix or custom_llm_provider) and the same model after stripping routing prefixes; otherwise fall back to the plain reasoning_content replay. Also keep the envelope out of the Anthropic thinking signatures when a session switches from an OpenRouter-routed Claude model to the direct Anthropic route.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Note: I saw @its-amann's offer to pick this up — this branch was already complete and locally verified when that comment landed, so submitting it now to avoid duplicated effort. Happy to coordinate if reviewing is easier another way.
Problem
Non-Anthropic reasoning models routed through OpenRouter return their reasoning as an opaque
reasoning_detailsarray — encrypted entries and signed texts included — which the provider requires clients to send back unchanged between tool calls.LiteLlmflattened this to a plainreasoning_contentstring when replaying an assistant turn, so the model lost its reasoning state on every tool round trip, and encrypted-only reasoning was dropped entirely. Anthropicthinking_blocksand Geminithought_signaturealready round-trip; this gives the OpenRouter-style format the same treatment.Solution
_message_to_generate_content_responsereadsmessage.provider_specific_fields["reasoning_details"]and stores the raw array as a JSON envelope ({"adk_reasoning_details": [...]}) on a thought part'sthought_signature— the same slot that already preserves Anthropic thinking signatures through session persistence. Encrypted-only reasoning (nothing displayable) gets a textless carrier thought part._content_to_message_paramreplays the stored array unchanged and in order as a top-levelreasoning_detailsfield on the assistant message, but only while the outgoing request still targets the same OpenRouter model. The envelope records the model that produced the details, and the replay is scoped to OpenRouter routes (aopenrouter/model prefix,litellm_proxy/openrouter/...transports, orcustom_llm_provider="openrouter") with the model compared after stripping the routing prefixes, so a session that moves to another provider or model sends the plainreasoning_contentreplay instead of a field the new route would reject. Envelopes recorded before the model was stored (or with no model reported) stay replayable to any OpenRouter model. Text-only reasoning (no captured details) keeps the existingreasoning_contentreplay, so nothing changes for providers that never returnreasoning_details.openrouter/anthropic/claude-*model strings do not match_is_anthropic_model, so OpenRouter-routed Claude models take the new replay path (covered by a test) while nativeanthropic/...keepsthinking_blocks. When a session does switch from an OpenRouter-routed Claude model to the directanthropic/...route, the stored envelope is kept out of the Anthropic thinking signatures instead of leaking into them.Known limitation: streamed responses are not covered. LiteLLM currently drops
reasoning_details-only deltas before they reach this adapter (BerriAI/litellm#48401), so there is nothing to capture on the streaming path yet; streaming sessions keep today's behavior. The inbound helper already accepts the delta-level shape, so capture starts working once LiteLLM delivers the deltas.Testing Plan
Unit Tests:
20 new tests in
tests/unittests/models/test_litellm.py: helper extraction (including empty/invalid lists), inbound capture (text+details, encrypted-only carrier, no-details unchanged, producing model stored), outbound replay (identical array next to tool calls, theopenrouter/anthropicrouting edge, text-only fallback unchanged, an opaque Gemini-style signature not misdecoded as details), replay scoping (cross-provider skip, cross-model skip, same-model replay, response-form model without the routing prefix,custom_llm_providerroute,litellm_proxytransport, envelopes without a stored model), no envelope leak into Anthropic thinking signatures after a provider switch, and two-turn tests throughgenerate_content_asyncwith a mocked client asserting the second request carries the identicalreasoning_detailsarray next to the original tool call — and does not carry it after the request switches to a different model.pytest summaries (at head 101d0ea):
tests/unittests/models/test_litellm.py: 834 passed.tests/unittests/models: 2063 passed.tox(py311/py312/py313/py314, full suite; run at e4bd38a, the head before the replay-scoping commit — that commit only toucheslite_llm.pyand its test file, both covered by themodels/run above): 24520/24511/24511/24513 passed. 13 tests fail in every environment — all in network-path tools tests (SSRF/DNS pinning intest_rest_api_tool.py,test_load_web_page.pyNAT64/pinning, one MCP google-auth transport test). The same 13 fail identically on pristinemainin the same virtualenv with the same command (verified by A/B), so they are pre-existing local-environment failures, not regressions; zero failures inmodels/or anywhere this change touches.Manual End-to-End (E2E) Tests:
KeyError: 'reasoning_details'(the reported drop); restored, it passes. Against the pre-scoping head, the five scoping tests fail exactly as designed (model never stored, replay sent to another provider, replay sent to another model, envelope leaked into an Anthropic signature, replay after a model switch); at the current head all 834 pass.uv.lockresolves): built the assistant message this change emits (top-levelreasoning_details+tool_calls) and passed it throughOpenrouterConfig().transform_request(...); the outgoing provider request JSON carries the array unchanged, element for element and in order, including thereasoning.encrypteddata and signatures.LlmAgentwithmodel=LiteLlm(model="openrouter/<reasoning model>")and one function tool, capture the second request vialitellm._turn_on_debug()or an HTTP proxy, and check the replayed assistant message now carries the fullreasoning_detailsarray.Checklist
Additional context
Sibling report #8110 (OpenAIResponsesLlm never replaying reasoning items) describes the same class of loss in the OpenAI Responses adapter; this PR only changes the LiteLLM adapter.