-
Notifications
You must be signed in to change notification settings - Fork 136
Description
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:
- Many deltas with text:
{ type: "reasoning.text", text: "fragment..." } - 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:
- Use a snapshot of
accumulatedReasoningDetailsfor the emittedproviderMetadata:
const reasoningMetadata = {
openrouter: {
reasoning_details: accumulatedReasoningDetails.map((d) => ({ ...d }))
}
};- 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.