Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/preserve-empty-reasoning-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@openrouter/ai-sdk-provider": patch
---

fix: preserve empty reasoning_details arrays in multi-turn conversations

Some providers (notably DeepSeek V4 in thinking mode) return `reasoning_details: []`
on turns where they produced no visible reasoning tokens. They require this empty array
to be sent back in subsequent requests to maintain conversation state; omitting it
causes 4xx errors on follow-up turns.

**`src/chat/index.ts`:**
- Stream finish event now always sets `openrouterMetadata.reasoning_details`, even when
the accumulated array is empty (previously guarded by `length > 0`).
- Both `reasoning-end` emit sites now always include `providerMetadata.openrouter.reasoning_details`,
removing the `length > 0` ternary that would drop the field entirely.

**`src/chat/convert-to-openrouter-chat-messages.ts`:**
- `candidateReasoningDetails` selection now uses `Array.isArray(messageReasoningDetails)`
instead of `messageReasoningDetails.length > 0` — an explicit `[]` is now treated as
"metadata was provided" rather than "metadata was absent", and no longer falls through
to `findFirstReasoningDetails`.
- The top-level `if (candidateReasoningDetails)` guard no longer requires `length > 0`;
an empty candidate array still triggers the dedup/signature-filter block.
- `finalReasoningDetails` is now always set to `uniqueDetails` (the deduplicated array),
never collapsed to `undefined`. When all entries were duplicate or signature-stripped,
the empty array is preserved as a meaningful signal.
- `effectiveReasoning` still requires `finalReasoningDetails.length > 0` — reasoning
text is never sent alongside an empty details array.
55 changes: 47 additions & 8 deletions src/chat/convert-to-openrouter-chat-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,39 @@ describe('reasoning_details accumulation', () => {
]);
});

it('should preserve empty reasoning_details array from message-level providerOptions (DeepSeek V4)', () => {
// DeepSeek V4 returns reasoning_details: [] on turns where it produced no
// reasoning tokens, and expects the empty array to be sent back in subsequent
// turns. Without this, the conversation state breaks on follow-up requests.
const result = convertToOpenRouterChatMessages([
{
role: 'assistant',
content: [
{
type: 'text',
text: 'Response with no reasoning this turn',
},
],
providerOptions: {
openrouter: {
reasoning_details: [], // explicitly empty — NOT the same as absent
},
},
},
]);

expect(result).toEqual([
{
role: 'assistant',
content: 'Response with no reasoning this turn',
// reasoning text must be omitted (no valid details)
reasoning: undefined,
// reasoning_details must be [] (not undefined) — empty array is a meaningful signal
reasoning_details: [],
},
]);
});

it('should not include reasoning or reasoning_details when not present in providerOptions', () => {
const result = convertToOpenRouterChatMessages([
{
Expand Down Expand Up @@ -1554,10 +1587,11 @@ describe('multi-turn reasoning_details deduplication (issue #254)', () => {
],
});

// Second assistant message should NOT have reasoning_details (duplicate ID)
// Second assistant message should have reasoning_details: [] (all entries were
// duplicate, but metadata was explicitly present — preserve empty array as signal)
expect(result[2]).toMatchObject({
role: 'assistant',
reasoning_details: undefined,
reasoning_details: [],
});
});

Expand Down Expand Up @@ -1694,11 +1728,12 @@ describe('multi-turn reasoning_details deduplication (issue #254)', () => {
],
});

// Second assistant message should NOT have reasoning_details (duplicate)
// Second assistant message should have reasoning_details: [] (all entries were
// duplicate, but metadata was explicitly present — preserve empty array as signal)
expect(result[2]).toMatchObject({
role: 'assistant',
content: 'Second response',
reasoning_details: undefined,
reasoning_details: [],
});
});

Expand Down Expand Up @@ -1894,13 +1929,15 @@ describe('issue #423: strip reasoning without valid signatures', () => {
},
]);

// reasoning.text without signature should be stripped, and since no
// valid reasoning_details remain, reasoning text should also be stripped
// reasoning.text without signature should be stripped. reasoning_details is []
// (not undefined) because the metadata was explicitly present — the empty array
// preserves the signal that this provider uses reasoning_details even when empty.
// reasoning text is still omitted (no valid details to pair with).
expect(result[0]).toMatchObject({
role: 'assistant',
content: 'The answer is 4.',
reasoning: undefined,
reasoning_details: undefined,
reasoning_details: [],
});
});

Expand Down Expand Up @@ -1930,11 +1967,13 @@ describe('issue #423: strip reasoning without valid signatures', () => {
},
]);

// reasoning_details is [] (not undefined): metadata was explicitly present, all
// entries had null signature and were stripped — preserve empty array as signal.
expect(result[0]).toMatchObject({
role: 'assistant',
content: 'Done.',
reasoning: undefined,
reasoning_details: undefined,
reasoning_details: [],
});
});

Expand Down
29 changes: 21 additions & 8 deletions src/chat/convert-to-openrouter-chat-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,15 @@ export function convertToOpenRouterChatMessages(
// Use message-level reasoning_details if available, otherwise find from parts
// Priority: message-level > first tool call > first reasoning part
// This prevents duplicate thinking blocks when Claude makes parallel tool calls
//
// NOTE: treat an empty array as a meaningful signal — some providers (e.g.
// DeepSeek V4) return `reasoning_details: []` on turns where they produced no
// visible reasoning tokens, and they expect to receive that empty array back in
// subsequent turns to maintain the conversation state. Falling back to
// `findFirstReasoningDetails` when the array exists-but-is-empty would silently
// drop this signal and cause those providers to error on follow-up requests.
const candidateReasoningDetails =
messageReasoningDetails &&
Array.isArray(messageReasoningDetails) &&
messageReasoningDetails.length > 0
messageReasoningDetails && Array.isArray(messageReasoningDetails)
? messageReasoningDetails
: findFirstReasoningDetails(content);

Expand Down Expand Up @@ -299,7 +304,7 @@ export function convertToOpenRouterChatMessages(
// never registered in the tracker — otherwise a signatureless entry
// in an earlier turn would suppress a valid signed copy in a later turn.
let finalReasoningDetails: ReasoningDetailUnion[] | undefined;
if (candidateReasoningDetails && candidateReasoningDetails.length > 0) {
if (candidateReasoningDetails) {
const validDetails = candidateReasoningDetails.filter((detail) => {
if (detail.type !== ReasoningDetailType.Text) {
return true;
Expand Down Expand Up @@ -339,11 +344,13 @@ export function convertToOpenRouterChatMessages(
uniqueDetails.push(detail);
}
}
finalReasoningDetails =
uniqueDetails.length > 0 ? uniqueDetails : undefined;
// Preserve the empty-array signal: when candidateReasoningDetails existed but
// all entries were duplicate or signature-stripped, still emit [] so downstream
// providers that require the field (e.g. DeepSeek) receive it.
finalReasoningDetails = uniqueDetails;
}

// Only include reasoning text if we have valid reasoning_details.
// Only include reasoning text if we have valid, non-empty reasoning_details.
// When providerMetadata is lost during message serialization or
// custom pruning (e.g., stripping providerOptions from reasoning
// parts), or when switching between models mid-conversation,
Expand All @@ -352,8 +359,14 @@ export function convertToOpenRouterChatMessages(
// construct thinking blocks without valid signatures, which
// Anthropic rejects with "Invalid signature in thinking block"
// (issue #423).
//
// Note: an empty finalReasoningDetails ([]) means the provider explicitly
// returned no reasoning tokens this turn — do not send reasoning text in
// that case either.
const effectiveReasoning =
reasoning && finalReasoningDetails ? reasoning : undefined;
reasoning && finalReasoningDetails && finalReasoningDetails.length > 0
? reasoning
: undefined;

messages.push({
role: 'assistant',
Expand Down
6 changes: 6 additions & 0 deletions src/chat/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1556,6 +1556,7 @@ describe('doStream', () => {

providerMetadata: {
openrouter: {
reasoning_details: [],
usage: {
completionTokens: 227,
promptTokens: 17,
Expand Down Expand Up @@ -2214,6 +2215,7 @@ describe('doStream', () => {
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
providerMetadata: {
openrouter: {
reasoning_details: [],
usage: {
completionTokens: 17,
promptTokens: 53,
Expand Down Expand Up @@ -2332,6 +2334,7 @@ describe('doStream', () => {
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
providerMetadata: {
openrouter: {
reasoning_details: [],
usage: {
completionTokens: 17,
promptTokens: 53,
Expand Down Expand Up @@ -2793,6 +2796,7 @@ describe('doStream', () => {
finishReason: { unified: 'stop', raw: 'stop' },
providerMetadata: {
openrouter: {
reasoning_details: [],
usage: {
completionTokens: 17,
promptTokens: 53,
Expand Down Expand Up @@ -2853,6 +2857,7 @@ describe('doStream', () => {
finishReason: { unified: 'error', raw: undefined },
providerMetadata: {
openrouter: {
reasoning_details: [],
usage: {},
},
},
Expand Down Expand Up @@ -2895,6 +2900,7 @@ describe('doStream', () => {
type: 'finish',
providerMetadata: {
openrouter: {
reasoning_details: [],
usage: {},
},
},
Expand Down
49 changes: 23 additions & 26 deletions src/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,18 +838,17 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: 'reasoning-end',
id: reasoningId || generateId(),
// Include accumulated reasoning_details so the AI SDK can update
// the reasoning part's providerMetadata with the correct signature.
// The signature typically arrives in the last reasoning delta,
// Always include accumulated reasoning_details so the AI SDK can
// update the reasoning part's providerMetadata with the correct
// signature. The signature typically arrives in the last delta,
// but reasoning-start only carries the first delta's metadata.
providerMetadata:
accumulatedReasoningDetails.length > 0
? {
openrouter: {
reasoning_details: accumulatedReasoningDetails,
},
}
: undefined,
// An empty array is intentional — it signals the provider produced
// no reasoning tokens this turn (e.g. DeepSeek V4).
providerMetadata: {
openrouter: {
reasoning_details: accumulatedReasoningDetails,
},
},
});
reasoningStarted = false; // Mark as ended so we don't end it again in flush
}
Expand Down Expand Up @@ -1179,16 +1178,14 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 {
controller.enqueue({
type: 'reasoning-end',
id: reasoningId || generateId(),
// Include accumulated reasoning_details so the AI SDK can update
// the reasoning part's providerMetadata with the correct signature.
providerMetadata:
accumulatedReasoningDetails.length > 0
? {
openrouter: {
reasoning_details: accumulatedReasoningDetails,
},
}
: undefined,
// Always include accumulated reasoning_details so the AI SDK can
// update the reasoning part's providerMetadata. An empty array is
// intentional — it signals the provider produced no reasoning tokens.
providerMetadata: {
openrouter: {
reasoning_details: accumulatedReasoningDetails,
},
},
});
}
if (textStarted) {
Expand All @@ -1212,11 +1209,11 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 {
openrouterMetadata.provider = provider;
}

// Include accumulated reasoning_details if any were received
if (accumulatedReasoningDetails.length > 0) {
openrouterMetadata.reasoning_details =
accumulatedReasoningDetails;
}
// Always include reasoning_details in finish metadata, even when empty.
// Some providers (e.g. DeepSeek V4) return reasoning_details: [] on turns
// where they produced no visible reasoning tokens, and they require the
// field to be sent back in subsequent turns to maintain conversation state.
openrouterMetadata.reasoning_details = accumulatedReasoningDetails;

// Include accumulated file annotations if any were received
if (accumulatedFileAnnotations.length > 0) {
Expand Down
29 changes: 16 additions & 13 deletions src/chat/signature-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => {

const assistantMsg = result.find((m) => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
// reasoning_details and reasoning should both be stripped
// because the only reasoning.text entry has no signature
expect(assistantMsg!.reasoning_details).toBeUndefined();
// reasoning_details is [] (not undefined): metadata was present but all entries
// lacked signatures and were stripped. reasoning text is still omitted.
expect(assistantMsg!.reasoning_details).toEqual([]);
expect(assistantMsg!.reasoning).toBeUndefined();
});

Expand Down Expand Up @@ -143,7 +143,8 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => {

const assistantMsg = result.find((m) => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
expect(assistantMsg!.reasoning_details).toBeUndefined();
// [] not undefined: metadata was present, all entries had null signature → stripped
expect(assistantMsg!.reasoning_details).toEqual([]);
expect(assistantMsg!.reasoning).toBeUndefined();
});

Expand Down Expand Up @@ -286,8 +287,9 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => {
const assistantMsg = result.find((m) => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
// Gemini reasoning.text is always stripped to prevent
// "Corrupted thought signature" errors on roundtrip
expect(assistantMsg!.reasoning_details).toBeUndefined();
// "Corrupted thought signature" errors on roundtrip.
// [] not undefined: metadata was present, entry stripped → preserve empty array.
expect(assistantMsg!.reasoning_details).toEqual([]);
expect(assistantMsg!.reasoning).toBeUndefined();
});

Expand Down Expand Up @@ -459,8 +461,8 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => {

const assistantMsg = result.find((m) => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
// Both entries should be stripped neither has a valid signature
expect(assistantMsg!.reasoning_details).toBeUndefined();
// Both entries stripped (neither has valid signature) → [] not undefined.
expect(assistantMsg!.reasoning_details).toEqual([]);
expect(assistantMsg!.reasoning).toBeUndefined();
});

Expand Down Expand Up @@ -548,8 +550,8 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => {

const assistantMsg = result.find((m) => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
// Should be stripped — defaults to Anthropic format which requires signature
expect(assistantMsg!.reasoning_details).toBeUndefined();
// Defaults to Anthropic format which requires signature → stripped → [] not undefined
expect(assistantMsg!.reasoning_details).toEqual([]);
expect(assistantMsg!.reasoning).toBeUndefined();
});

Expand Down Expand Up @@ -587,8 +589,8 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => {

const assistantMsg = result.find((m) => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
// Empty string signature is invalid — should be stripped
expect(assistantMsg!.reasoning_details).toBeUndefined();
// Empty string signature is invalid → stripped → [] not undefined
expect(assistantMsg!.reasoning_details).toEqual([]);
expect(assistantMsg!.reasoning).toBeUndefined();
});

Expand Down Expand Up @@ -624,7 +626,8 @@ describe('Issue #423/#439: reasoning signature in multi-turn messages', () => {

const assistantMsg = result.find((m) => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
expect(assistantMsg!.reasoning_details).toBeUndefined();
// [] not undefined: message-level metadata was present, all entries stripped
expect(assistantMsg!.reasoning_details).toEqual([]);
expect(assistantMsg!.reasoning).toBeUndefined();
});

Expand Down
Loading
Loading