diff --git a/.changeset/preserve-empty-reasoning-details.md b/.changeset/preserve-empty-reasoning-details.md new file mode 100644 index 00000000..c389aae8 --- /dev/null +++ b/.changeset/preserve-empty-reasoning-details.md @@ -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. diff --git a/src/chat/convert-to-openrouter-chat-messages.test.ts b/src/chat/convert-to-openrouter-chat-messages.test.ts index bfddfcbd..862dde04 100644 --- a/src/chat/convert-to-openrouter-chat-messages.test.ts +++ b/src/chat/convert-to-openrouter-chat-messages.test.ts @@ -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([ { @@ -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: [], }); }); @@ -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: [], }); }); @@ -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: [], }); }); @@ -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: [], }); }); diff --git a/src/chat/convert-to-openrouter-chat-messages.ts b/src/chat/convert-to-openrouter-chat-messages.ts index 352f22bf..c1f50a59 100644 --- a/src/chat/convert-to-openrouter-chat-messages.ts +++ b/src/chat/convert-to-openrouter-chat-messages.ts @@ -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); @@ -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; @@ -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, @@ -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', diff --git a/src/chat/index.test.ts b/src/chat/index.test.ts index c5f8cb37..350f677e 100644 --- a/src/chat/index.test.ts +++ b/src/chat/index.test.ts @@ -1556,6 +1556,7 @@ describe('doStream', () => { providerMetadata: { openrouter: { + reasoning_details: [], usage: { completionTokens: 227, promptTokens: 17, @@ -2214,6 +2215,7 @@ describe('doStream', () => { finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, providerMetadata: { openrouter: { + reasoning_details: [], usage: { completionTokens: 17, promptTokens: 53, @@ -2332,6 +2334,7 @@ describe('doStream', () => { finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, providerMetadata: { openrouter: { + reasoning_details: [], usage: { completionTokens: 17, promptTokens: 53, @@ -2793,6 +2796,7 @@ describe('doStream', () => { finishReason: { unified: 'stop', raw: 'stop' }, providerMetadata: { openrouter: { + reasoning_details: [], usage: { completionTokens: 17, promptTokens: 53, @@ -2853,6 +2857,7 @@ describe('doStream', () => { finishReason: { unified: 'error', raw: undefined }, providerMetadata: { openrouter: { + reasoning_details: [], usage: {}, }, }, @@ -2895,6 +2900,7 @@ describe('doStream', () => { type: 'finish', providerMetadata: { openrouter: { + reasoning_details: [], usage: {}, }, }, diff --git a/src/chat/index.ts b/src/chat/index.ts index 2587282b..ebab2ba0 100644 --- a/src/chat/index.ts +++ b/src/chat/index.ts @@ -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 } @@ -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) { @@ -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) { diff --git a/src/chat/signature-roundtrip.test.ts b/src/chat/signature-roundtrip.test.ts index 81066152..7aa29971 100644 --- a/src/chat/signature-roundtrip.test.ts +++ b/src/chat/signature-roundtrip.test.ts @@ -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(); }); @@ -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(); }); @@ -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(); }); @@ -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(); }); @@ -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(); }); @@ -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(); }); @@ -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(); }); diff --git a/src/tests/stream-usage-accounting.test.ts b/src/tests/stream-usage-accounting.test.ts index 781ddae8..6b377dd4 100644 --- a/src/tests/stream-usage-accounting.test.ts +++ b/src/tests/stream-usage-accounting.test.ts @@ -171,9 +171,11 @@ describe('OpenRouter Streaming Usage Accounting', () => { const finishChunk = chunks.find((chunk) => chunk.type === 'finish'); expect(finishChunk).toBeDefined(); - // Verify that provider metadata is not included + // Verify that provider metadata is as expected. + // reasoning_details is always present ([] when no reasoning was received). expect(finishChunk?.providerMetadata?.openrouter).toStrictEqual({ usage: {}, + reasoning_details: [], }); });