-
Notifications
You must be signed in to change notification settings - Fork 30
Description
Summary
Claude reasoning/thinking blocks via OpenRouter and Kilo Gateway are fundamentally broken in multi-turn conversations. The stored reasoning metadata from streaming gets corrupted, causing 4x duplication and Anthropic signature validation failures on subsequent turns.
Related: #236
Related Upstream Issues
There are two distinct but related upstream issues:
-
OpenRouter AI SDK provider duplicates reasoning_details 4x (ai-sdk-provider#339). When Claude returns reasoning with tool calls, the SDK incorrectly attaches reasoning_details to every tool call part. The first copy is corrupt — truncated text with no signature. This was closed by PR #344, so it may be fixed in newer versions of the SDK.
-
Signature validation fails when thinking blocks are modified (opencode#9364, opencode#11546, Reddit thread). Anthropic requires that thinking/redacted_thinking blocks sent back in subsequent turns must be byte-for-byte identical to what was originally returned. OpenCode's message reconstruction (storage → toModelMessage) doesn't guarantee this, and any modification (reordering, truncation, re-serialization) breaks the signature.
The OpenRouter docs say that passing reasoning back as a plain reasoning string (rather than structured reasoning_details) should work for preserving reasoning across turns, but this hasn't been validated end-to-end in our setup.
SDK Version Blocker
The reasoning_details duplication bug is fixed upstream in @openrouter/ai-sdk-provider@2.0.4+, but the fix requires AI SDK v6 (ai@^6.0.0) — it's a breaking change.
- Kilo is on
@openrouter/ai-sdk-provider@1.5.4(v1.x branch) - The fix was released Jan 27, 2026 in the v2.x line which requires
ai@^6.0.0 - Until Kilo upgrades to AI SDK v6 + OpenRouter provider v2.x, the reasoning duplication bug will persist for Claude via OpenRouter/Kilo Gateway
- This confirms the current approach (disable reasoning for Claude via the gateway) is the right call for now
Root Cause Analysis
1. Corrupt metadata during streaming
The OpenRouter SDK sends per-chunk reasoning_details in each reasoning-delta event's providerMetadata. Each delta contains only that chunk's text fragment — not the accumulated text, and no signature.
In processor.ts:83, the code overwrites part.metadata on every delta:
if (value.providerMetadata) part.metadata = value.providerMetadataAfter streaming completes, the stored metadata contains only the last chunk's partial text (e.g. "to be done.") with no signature — this is the corrupt block.
2. 4x duplication in outgoing requests
When the stored message is reconstructed and sent back to the API, the same reasoning content flows through 4 locations:
-
type: "reasoning"part in ModelMessage content — created from stored text inmessage-v2.ts:626-632. The AI SDK converts this to a reasoning part withproviderOptionsfrompart.metadata. -
providerOptions.openrouter.reasoning_detailson the reasoning part — the corrupt metadata from the delta. The OpenRouter SDK'sconvertToOpenRouterChatMessagesreads this and pushes it intoaccumulatedReasoningDetails. -
Message-level
providerOptions— whenapplyCachingruns, it addsproviderOptionsto messages. If reasoning metadata exists at message level, the OpenRouter SDK reads it too. -
The
reasoningfield — the OpenRouter SDK concatenates all reasoning parts' text into a plainreasoningstring and sends it alongsidereasoning_details.
The OpenRouter SDK sends BOTH reasoning (plain text) and reasoning_details (structured) on the wire. The API receives the same content from multiple fields, with the first reasoning_details entry being corrupt (truncated text, missing signature), failing Anthropic's signature validation.
3. reasoning vs reasoning_details in requests
Per the OpenRouter docs, there are two ways to preserve reasoning across turns:
message.reasoning(string) — plain text, simplermessage.reasoning_details(array) — structured with signatures, needed for "encrypted or summarized" reasoning types
The OpenRouter SDK currently sends reasoning (plain text) from the type: "reasoning" ModelMessage parts, and reasoning_details from providerOptions. With our current stripping approach, only reasoning would be sent if we stopped stripping — but reasoning_details would be corrupt.
4. cache_control placement
The applyCaching function in transform.ts sets providerOptions.openrouter.cacheControl which the OpenRouter SDK maps to cache_control on the message object. The Kilo Gateway separately adds cache_control on the content parts. OpenRouter docs show it on content parts for Anthropic. Both may work but this should be confirmed with OpenRouter — the message-level placement is what the OpenRouter SDK produces from providerOptions.
Current Workaround
The pragmatic fix (being implemented in a follow-up PR) is:
- Strip all thinking/reasoning blocks from messages sent to OpenRouter/Kilo Gateway (prevents corrupt data from reaching the API)
- Remove hardcoded reasoning effort for Claude via Kilo Gateway (don't force reasoning on)
- Keep prompt caching enabled for OpenRouter/Kilo Gateway
This means Claude reasoning via the gateway is effectively disabled until the duplication issue is properly resolved.
Proper Fix (Future)
To properly support Claude reasoning via OpenRouter/Kilo Gateway:
-
Upgrade to AI SDK v6 +
@openrouter/ai-sdk-provider@2.x— this is the primary blocker. The v2.x line fixes the 4x reasoning_details duplication (PR #344). This is a major version bump requiringai@^6.0.0. -
After upgrading, validate that reasoning blocks are correctly preserved across turns (no duplication, valid signatures).
-
Fix
cache_controlplacement — confirm with OpenRouter whether message-levelcache_controlworks for Anthropic prompt caching, or if it needs to be on content parts only.
Files Involved
packages/opencode/src/provider/transform.ts— message transformation, caching, reasoning strippingpackages/opencode/src/session/message-v2.ts— message reconstruction from storagepackages/opencode/src/session/processor.ts— streaming delta handling- OpenRouter SDK:
@openrouter/ai-sdk-providerconvertToOpenRouterChatMessagesfunction