Skip to content

Reasoning blocks for Claude via OpenRouter/Kilo Gateway are broken (4x duplication, corrupt signatures) #303

@marius-kilocode

Description

@marius-kilocode

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:

  1. 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.

  2. 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.providerMetadata

After 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:

  1. type: "reasoning" part in ModelMessage content — created from stored text in message-v2.ts:626-632. The AI SDK converts this to a reasoning part with providerOptions from part.metadata.

  2. providerOptions.openrouter.reasoning_details on the reasoning part — the corrupt metadata from the delta. The OpenRouter SDK's convertToOpenRouterChatMessages reads this and pushes it into accumulatedReasoningDetails.

  3. Message-level providerOptions — when applyCaching runs, it adds providerOptions to messages. If reasoning metadata exists at message level, the OpenRouter SDK reads it too.

  4. The reasoning field — the OpenRouter SDK concatenates all reasoning parts' text into a plain reasoning string and sends it alongside reasoning_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, simpler
  • message.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:

  1. Strip all thinking/reasoning blocks from messages sent to OpenRouter/Kilo Gateway (prevents corrupt data from reaching the API)
  2. Remove hardcoded reasoning effort for Claude via Kilo Gateway (don't force reasoning on)
  3. 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:

  1. 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 requiring ai@^6.0.0.

  2. After upgrading, validate that reasoning blocks are correctly preserved across turns (no duplication, valid signatures).

  3. Fix cache_control placement — confirm with OpenRouter whether message-level cache_control works 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 stripping
  • packages/opencode/src/session/message-v2.ts — message reconstruction from storage
  • packages/opencode/src/session/processor.ts — streaming delta handling
  • OpenRouter SDK: @openrouter/ai-sdk-provider convertToOpenRouterChatMessages function

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions