Skip to content

Commit 53e1ff0

Browse files
fix: preserve reasoning_details shape to prevent malformed responses (#10313)
1 parent ded6486 commit 53e1ff0

File tree

4 files changed

+451
-28
lines changed

4 files changed

+451
-28
lines changed

src/api/providers/openrouter.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
363363
}
364364

365365
let lastUsage: CompletionUsage | undefined = undefined
366-
// Accumulator for reasoning_details: accumulate text by type-index key
366+
// Accumulator for reasoning_details FROM the API.
367+
// We preserve the original shape of reasoning_details to prevent malformed responses.
367368
const reasoningDetailsAccumulator = new Map<
368369
string,
369370
{
@@ -378,6 +379,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
378379
}
379380
>()
380381

382+
// Track whether we've yielded displayable text from reasoning_details.
383+
// When reasoning_details has displayable content (reasoning.text or reasoning.summary),
384+
// we skip yielding the top-level reasoning field to avoid duplicate display.
385+
let hasYieldedReasoningFromDetails = false
386+
381387
for await (const chunk of stream) {
382388
// OpenRouter returns an error object instead of the OpenAI SDK throwing an error.
383389
if ("error" in chunk) {
@@ -440,22 +446,28 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
440446
}
441447

442448
// Yield text for display (still fragmented for live streaming)
449+
// Only reasoning.text and reasoning.summary have displayable content
450+
// reasoning.encrypted is intentionally skipped as it contains redacted content
443451
let reasoningText: string | undefined
444452
if (detail.type === "reasoning.text" && typeof detail.text === "string") {
445453
reasoningText = detail.text
446454
} else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") {
447455
reasoningText = detail.summary
448456
}
449-
// Note: reasoning.encrypted types are intentionally skipped as they contain redacted content
450457

451458
if (reasoningText) {
459+
hasYieldedReasoningFromDetails = true
452460
yield { type: "reasoning", text: reasoningText }
453461
}
454462
}
455-
} else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
456-
// Handle legacy reasoning format - only if reasoning_details is not present
457-
// See: https://openrouter.ai/docs/use-cases/reasoning-tokens
458-
yield { type: "reasoning", text: delta.reasoning }
463+
}
464+
465+
// Handle top-level reasoning field for UI display.
466+
// Skip if we've already yielded from reasoning_details to avoid duplicate display.
467+
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
468+
if (!hasYieldedReasoningFromDetails) {
469+
yield { type: "reasoning", text: delta.reasoning }
470+
}
459471
}
460472

461473
// Emit raw tool call chunks - NativeToolCallParser handles state management
@@ -490,7 +502,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
490502
}
491503
}
492504

493-
// After streaming completes, store the accumulated reasoning_details
505+
// After streaming completes, store ONLY the reasoning_details we received from the API.
494506
if (reasoningDetailsAccumulator.size > 0) {
495507
this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values())
496508
}

src/api/providers/roo.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
146146
const stream = await this.createStream(systemPrompt, messages, metadata, { headers })
147147

148148
let lastUsage: RooUsage | undefined = undefined
149-
// Accumulator for reasoning_details: accumulate text by type-index key
149+
// Accumulator for reasoning_details FROM the API.
150+
// We preserve the original shape of reasoning_details to prevent malformed responses.
150151
const reasoningDetailsAccumulator = new Map<
151152
string,
152153
{
@@ -161,6 +162,11 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
161162
}
162163
>()
163164

165+
// Track whether we've yielded displayable text from reasoning_details.
166+
// When reasoning_details has displayable content (reasoning.text or reasoning.summary),
167+
// we skip yielding the top-level reasoning field to avoid duplicate display.
168+
let hasYieldedReasoningFromDetails = false
169+
164170
for await (const chunk of stream) {
165171
const delta = chunk.choices[0]?.delta
166172
const finishReason = chunk.choices[0]?.finish_reason
@@ -223,29 +229,32 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
223229
}
224230

225231
// Yield text for display (still fragmented for live streaming)
232+
// Only reasoning.text and reasoning.summary have displayable content
233+
// reasoning.encrypted is intentionally skipped as it contains redacted content
226234
let reasoningText: string | undefined
227235
if (detail.type === "reasoning.text" && typeof detail.text === "string") {
228236
reasoningText = detail.text
229237
} else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") {
230238
reasoningText = detail.summary
231239
}
232-
// Note: reasoning.encrypted types are intentionally skipped as they contain redacted content
233240

234241
if (reasoningText) {
242+
hasYieldedReasoningFromDetails = true
235243
yield { type: "reasoning", text: reasoningText }
236244
}
237245
}
238-
} else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
239-
// Handle legacy reasoning format - only if reasoning_details is not present
240-
yield {
241-
type: "reasoning",
242-
text: delta.reasoning,
246+
}
247+
248+
// Handle top-level reasoning field for UI display.
249+
// Skip if we've already yielded from reasoning_details to avoid duplicate display.
250+
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
251+
if (!hasYieldedReasoningFromDetails) {
252+
yield { type: "reasoning", text: delta.reasoning }
243253
}
244254
} else if ("reasoning_content" in delta && typeof delta.reasoning_content === "string") {
245255
// Also check for reasoning_content for backward compatibility
246-
yield {
247-
type: "reasoning",
248-
text: delta.reasoning_content,
256+
if (!hasYieldedReasoningFromDetails) {
257+
yield { type: "reasoning", text: delta.reasoning_content }
249258
}
250259
}
251260

@@ -282,7 +291,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
282291
}
283292
}
284293

285-
// After streaming completes, store the accumulated reasoning_details
294+
// After streaming completes, store ONLY the reasoning_details we received from the API.
286295
if (reasoningDetailsAccumulator.size > 0) {
287296
this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values())
288297
}

0 commit comments

Comments
 (0)