Skip to content

Anthropic thinking block signature lost during streaming — causes Invalid signature in thinking block on multi-turn conversations #423

@egorprnn

Description

@egorprnn

Version

@openrouter/ai-sdk-provider@2.1.1 with ai@^5.x

Description

When streaming responses from Anthropic models (e.g. anthropic/claude-sonnet-4.5, anthropic/claude-opus-4.6) via OpenRouter, the signature field from reasoning.text details is never propagated to the AI SDK's reasoning part providerMetadata. This causes all subsequent multi-turn requests to fail with:

invalid_request_error: messages.1.content.0: Invalid `signature` in `thinking` block

Root Cause

The bug is in doStream inside OpenRouterChatLanguageModel. During streaming, Anthropic sends reasoning in multiple deltas:

  1. Many deltas with text: { type: "reasoning.text", text: "fragment..." }
  2. A final delta with only the signature: { type: "reasoning.text", signature: "eyJ...", format: "anthropic-claude-v1" }

The SDK has two issues in the streaming handler:

Issue 1 — The providerMetadata emitted with each reasoning-delta event uses the raw delta.reasoning_details instead of accumulatedReasoningDetails:

// dist/index.js ~line 3477
const reasoningMetadata = {
  openrouter: {
    reasoning_details: delta.reasoning_details  // ← only current delta, not accumulated
  }
};

Issue 2 — Signature-only deltas (no text) are silently dropped because of the if (detail.text) guard:

// dist/index.js ~line 3484
case "reasoning.text": {
  if (detail.text) {                           // ← signature-only delta has no text, skipped
    emitReasoningChunk(detail.text, reasoningMetadata);
  }
  break;
}

The correctly accumulated accumulatedReasoningDetails (which does contain both full text and signature) is only placed on the step-level finish event (~line 3732), which does not propagate back to individual reasoning parts' providerMetadata.

Effect

The stored UIMessage reasoning part ends up with:

{
  "type": "reasoning",
  "text": "The user wants to find the z-value such that...(full text)...",
  "providerMetadata": {
    "openrouter": {
      "reasoning_details": [
        {
          "type": "reasoning.text",
          "text": " looking for.",          
          "format": "anthropic-claude-v1"   
        }
      ]
    }
  }
}

Note: reasoning_details contains only the last text-bearing delta fragment and no signature field. On the next turn, findFirstReasoningDetails() reads this incomplete data and sends a thinking block without a valid signature — Anthropic rejects it with HTTP 400.

Steps to Reproduce

import { streamText, convertToModelMessages } from "ai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";

const openrouter = createOpenRouter({ apiKey: "..." });

// Turn 1 — get a response with reasoning
const result = await streamText({
  model: openrouter.chat("anthropic/claude-sonnet-4.5", {
    reasoning: { effort: "low" }
  }),
  messages: [{ role: "user", content: "What is 2+2?" }]
});

// Collect the UIMessage stream and store messages
const messages = []; // collect from result.toUIMessageStream()
// ... store messages (e.g. in a database)

// Turn 2 — include stored messages as history
const result2 = await streamText({
  model: openrouter.chat("anthropic/claude-sonnet-4.5", {
    reasoning: { effort: "low" }
  }),
  messages: [
    ...await convertToModelMessages(messages), // ← stored messages from Turn 1
    { role: "user", content: "Now what is 3+3?" }
  ]
});
// ❌ APICallError: Provider returned error
// "messages.1.content.0: Invalid `signature` in `thinking` block"

Suggested Fix

In doStream, two changes:

  1. Use a snapshot of accumulatedReasoningDetails for the emitted providerMetadata:
const reasoningMetadata = {
  openrouter: {
    reasoning_details: accumulatedReasoningDetails.map((d) => ({ ...d }))
  }
};
  1. Emit a metadata-propagation event for signature-only deltas:
case "reasoning.text": {
  if (detail.text) {
    emitReasoningChunk(detail.text, reasoningMetadata);
  } else if (detail.signature) {
    emitReasoningChunk("", reasoningMetadata);
  }
  break;
}

This ensures the AI SDK receives the full accumulated reasoning_details (with signature) on the last reasoning chunk, which then gets stored correctly in the UIMessage's providerMetadata.

Workaround

Strip reasoning parts from history before converting to model messages:

const sanitized = messages.map((msg) =>
  msg.role === "assistant" && msg.parts
    ? { ...msg, parts: msg.parts.filter((p) => p.type !== "reasoning") }
    : msg
);

This loses reasoning context but prevents the signature error.

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