Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/snapshot-late-reasoning-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openrouter/ai-sdk-provider': patch
---

Preserve `reasoning_details` that arrive after the reasoning block has closed (e.g. a late `reasoning.encrypted` blob or signature-only delta after text started) across turns. `doStream` now attaches a snapshot of the details to each carrier part and, when details arrived after the last carrier, emits an empty reasoning part carrying the complete array. The message converter replays the most complete carrier, so every detail is sent back exactly once, including when the stream is serialized over the wire (e.g. the UI message stream).
105 changes: 105 additions & 0 deletions src/chat/convert-to-openrouter-chat-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2043,6 +2043,111 @@ describe('multi-turn reasoning_details deduplication (issue #254)', () => {
});
});

describe('late reasoning_details carrier selection', () => {
const unsigned = {
type: ReasoningDetailType.Text,
text: 'Thinking.',
format: 'anthropic-claude-v1',
} as const;
const signed = { ...unsigned, signature: 'sig-late' };
const summary = {
type: ReasoningDetailType.Summary,
summary: 'Summary.',
} as const;
const encrypted = {
type: ReasoningDetailType.Encrypted,
id: 'rs_late',
data: 'blob',
} as const;

it('replays the later carrier when a late detail was merged in place', () => {
const result = convertToOpenRouterChatMessages([
{
role: 'assistant',
content: [
{
type: 'reasoning',
text: 'Thinking.',
providerOptions: {
openrouter: { reasoning_details: [unsigned] },
},
},
{ type: 'text', text: 'Answer' },
{
type: 'reasoning',
text: '',
providerOptions: { openrouter: { reasoning_details: [signed] } },
},
],
},
]);

expect(result[0]).toMatchObject({ reasoning_details: [signed] });
});

it('keeps the first of equally sized carriers that hold different details', () => {
const first = { ...signed, text: 'First.', signature: 'sig-1' };
const second = { ...signed, text: 'Second.', signature: 'sig-2' };
const result = convertToOpenRouterChatMessages([
{
role: 'assistant',
content: [
{
type: 'reasoning',
text: 'First.',
providerOptions: { openrouter: { reasoning_details: [first] } },
},
{
type: 'reasoning',
text: 'Second.',
providerOptions: { openrouter: { reasoning_details: [second] } },
},
{ type: 'text', text: 'Answer' },
],
},
]);

expect(result[0]).toMatchObject({ reasoning_details: [first] });
});

it('replays the most complete carrier over an earlier tool call', () => {
const result = convertToOpenRouterChatMessages([
{
role: 'assistant',
content: [
{
type: 'reasoning',
text: 'Summary.',
providerOptions: {
openrouter: { reasoning_details: [summary] },
},
},
{
type: 'tool-call',
toolCallId: 'call-1',
toolName: 'lookup',
input: {},
providerOptions: {
openrouter: { reasoning_details: [summary] },
},
},
{
type: 'reasoning',
text: '',
providerOptions: {
openrouter: { reasoning_details: [summary, encrypted] },
},
},
],
},
]);

expect(result[0]).toMatchObject({
reasoning_details: [summary, encrypted],
});
});
});

describe('issue #423: strip reasoning without valid signatures', () => {
it('should strip reasoning text when providerOptions exist but reasoning_details are empty', () => {
const result = convertToOpenRouterChatMessages([
Expand Down
97 changes: 61 additions & 36 deletions src/chat/convert-to-openrouter-chat-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,20 +301,19 @@ export function convertToOpenRouterChatMessages(
? parsedProviderOptions.data?.openrouter?.annotations
: undefined;

// 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
// Use message-level reasoning_details if available, otherwise the most
// complete carrier among the parts (see findMostCompleteReasoningDetails)
//
// 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
// `findMostCompleteReasoningDetails` 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
: findFirstReasoningDetails(content);
: findMostCompleteReasoningDetails(content);

// Strip reasoning.text entries that would cause signature errors
// when sent back to the upstream provider.
Expand Down Expand Up @@ -540,49 +539,75 @@ function mapToolResultContentParts(
}

/**
* Find the first reasoning_details from content parts.
* Priority: tool calls (complete accumulated data) > reasoning parts (delta data)
* Find the most complete reasoning_details carried by the content parts.
*
* This prevents duplicate thinking blocks when Claude makes parallel tool calls,
* as each tool call may have the same reasoning_details attached.
* Streamed carriers (reasoning parts and the first tool call) each hold a
* cumulative snapshot, and an empty reasoning part emitted at the end of the
* stream carries details that arrived after the earlier carriers. The carrier
* with the most entries wins; on a tie, a later carrier wins only when it
* continues the current one (e.g. a late signature merged in place), so
* separate per-detail parts keep the first one. Parallel tool calls after the
* first carry none, so thinking blocks are not duplicated.
*/
function findFirstReasoningDetails(
function findMostCompleteReasoningDetails(
content: Array<{
type: string;
providerOptions?: Record<string, unknown>;
}>,
): ReasoningDetailUnion[] | undefined {
// First, try tool calls - they have complete accumulated reasoning_details
let mostComplete: ReasoningDetailUnion[] | undefined;
for (const part of content) {
if (part.type === 'tool-call') {
const parsed = OpenRouterProviderOptionsSchema.safeParse(
part.providerOptions,
);
if (
parsed.success &&
parsed.data?.openrouter?.reasoning_details &&
parsed.data.openrouter.reasoning_details.length > 0
) {
return parsed.data.openrouter.reasoning_details;
}
if (part.type !== 'tool-call' && part.type !== 'reasoning') {
continue;
}
const parsed = OpenRouterProviderOptionsSchema.safeParse(
part.providerOptions,
);
const details = parsed.success
? parsed.data?.openrouter?.reasoning_details
: undefined;
if (!details || details.length === 0) {
continue;
}
if (
mostComplete === undefined ||
details.length > mostComplete.length ||
(details.length === mostComplete.length &&
mostComplete.every((detail, i) =>
continuesReasoningDetail(detail, details[i]),
))
) {
mostComplete = details;
Comment on lines +572 to +580

@devin-ai-integration devin-ai-integration Bot Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Earlier reasoning blocks disappear on replay

When doGenerate returns multiple reasoning parts without tool calls, findMostCompleteReasoningDetails keeps only the last part on a tie. Earlier signed thinking blocks disappear from the next request.

Learn more

Non-streaming responses from doGenerate give each reasoning part a one-element reasoning_details array, unlike streaming's cumulative snapshots. The converter now replaces an earlier equally sized array with the last one. Thus it discards reasoning that the earlier implementation retained, and cannot reconstruct the whole response by choosing one carrier.

Example: A response has two signed Anthropic thinking details, First and Second, and no tool call. doGenerate returns [First] and [Second] on separate reasoning parts. The next request now sends only [Second], not both blocks.

Recommended fix: Preserve the distinction between cumulative streamed carriers and per-detail non-streamed parts. Aggregate distinct non-streamed reasoning parts in order, while selecting a single cumulative snapshot for streamed parts and tool calls; test both paths with multiple signed blocks and mixed summary/encrypted details.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in the latest commit: on an equal-length tie, a later carrier now wins only if it continues the current one entry by entry (an in-place merge such as a late signature). So separate per-detail doGenerate parts like [First], [Second] keep [First], as on main, and there's a test for it. Combining per-detail doGenerate parts is in scope for #600: its first reasoning part carries the full array, and 'most entries wins' selects it.

}
}

// Fall back to reasoning parts - they have delta reasoning_details
for (const part of content) {
if (part.type === 'reasoning') {
const parsed = OpenRouterProviderOptionsSchema.safeParse(
part.providerOptions,
return mostComplete;
}

/**
* Whether `next` is the same reasoning detail as `prev`, possibly extended
* by later deltas merged in place (more text, or a signature added).
*/
function continuesReasoningDetail(
prev: ReasoningDetailUnion,
next: ReasoningDetailUnion | undefined,
): boolean {
if (next?.type !== prev.type) {
return false;
}
switch (next.type) {
case ReasoningDetailType.Text: {
const prevText = prev as typeof next;
return (
(next.text ?? '').startsWith(prevText.text ?? '') &&
(!prevText.signature || prevText.signature === next.signature)
);
if (
parsed.success &&
parsed.data?.openrouter?.reasoning_details &&
parsed.data.openrouter.reasoning_details.length > 0
) {
return parsed.data.openrouter.reasoning_details;
}
}
case ReasoningDetailType.Summary:
return next.summary.startsWith((prev as typeof next).summary);
case ReasoningDetailType.Encrypted:
return next.data === (prev as typeof next).data;
default:
return false;
}

return undefined;
}
Loading
Loading