Skip to content

Commit b94dde1

Browse files
committed
fix: prevent double reasoning display and OpenAI 404 errors for reasoning_details
- Add tracking flags to prevent double display when both reasoning_details and top-level reasoning field contain the same text - Selectively strip 'id' field only from openai-responses-v1 blocks to avoid 404 errors (since we don't use store: true) - Preserve 'id' field for other formats like xai-responses-v1 - Handle reasoning_details on string-content messages that were previously losing this data during transformation - Add comprehensive test coverage for reasoning_details handling
1 parent 0f3df0e commit b94dde1

File tree

4 files changed

+492
-28
lines changed

4 files changed

+492
-28
lines changed

src/api/providers/openrouter.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
361361
}
362362

363363
let lastUsage: CompletionUsage | undefined = undefined
364-
// Accumulator for reasoning_details: accumulate text by type-index key
364+
// Accumulator for reasoning_details FROM the API.
365+
// IMPORTANT: We do not synthesize/add any reasoning blocks from the top-level `reasoning` field.
366+
// The top-level `reasoning` is treated as display-only.
365367
const reasoningDetailsAccumulator = new Map<
366368
string,
367369
{
@@ -376,6 +378,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
376378
}
377379
>()
378380

381+
// Track whether we've seen reasoning_details in this stream
382+
// When reasoning_details are present, the top-level reasoning field is redundant
383+
// and should be ignored for storage (but may still be used for display)
384+
let hasReasoningDetails = false
385+
// Track whether we've yielded any displayable text from reasoning_details
386+
// This prevents double-display when both reasoning_details (with summary) and
387+
// top-level reasoning field contain the same text
388+
let hasYieldedReasoningFromDetails = false
389+
379390
for await (const chunk of stream) {
380391
// OpenRouter returns an error object instead of the OpenAI SDK throwing an error.
381392
if ("error" in chunk) {
@@ -403,6 +414,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
403414
}
404415

405416
if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) {
417+
hasReasoningDetails = true
406418
for (const detail of deltaWithReasoning.reasoning_details) {
407419
const index = detail.index ?? 0
408420
const key = `${detail.type}-${index}`
@@ -438,22 +450,41 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
438450
}
439451

440452
// Yield text for display (still fragmented for live streaming)
453+
// Only reasoning.text and reasoning.summary have displayable content
454+
// reasoning.encrypted is intentionally skipped as it contains redacted content
441455
let reasoningText: string | undefined
442456
if (detail.type === "reasoning.text" && typeof detail.text === "string") {
443457
reasoningText = detail.text
444458
} else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") {
445459
reasoningText = detail.summary
446460
}
447-
// Note: reasoning.encrypted types are intentionally skipped as they contain redacted content
448461

449462
if (reasoningText) {
463+
hasYieldedReasoningFromDetails = true
450464
yield { type: "reasoning", text: reasoningText }
451465
}
452466
}
453-
} else if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
454-
// Handle legacy reasoning format - only if reasoning_details is not present
455-
// See: https://openrouter.ai/docs/use-cases/reasoning-tokens
456-
yield { type: "reasoning", text: delta.reasoning }
467+
}
468+
469+
// Handle top-level reasoning field for UI display
470+
// IMPORTANT: The top-level `reasoning` field is ONLY for display purposes
471+
// when reasoning_details exists. It should NOT be stored or sent back - only
472+
// the original `reasoning_details` array received from the API should be stored.
473+
//
474+
// Display rules:
475+
// - If we've already yielded text from reasoning_details, DON'T yield top-level reasoning
476+
// (they contain the same text, would cause double display)
477+
// - If reasoning_details only has encrypted content (no displayable text), then yield
478+
// top-level reasoning for display
479+
// - If no reasoning_details exist, yield for display
480+
//
481+
// Storage rules:
482+
// - Never store top-level `reasoning`. Only store what the API returns in `reasoning_details`.
483+
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
484+
// Only yield for display if we haven't already yielded from reasoning_details
485+
if (!hasYieldedReasoningFromDetails) {
486+
yield { type: "reasoning", text: delta.reasoning }
487+
}
457488
}
458489

459490
// Emit raw tool call chunks - NativeToolCallParser handles state management
@@ -488,7 +519,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
488519
}
489520
}
490521

491-
// After streaming completes, store the accumulated reasoning_details
522+
// After streaming completes, store ONLY the reasoning_details we received from the API.
492523
if (reasoningDetailsAccumulator.size > 0) {
493524
this.currentReasoningDetails = Array.from(reasoningDetailsAccumulator.values())
494525
}

src/api/providers/roo.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,9 @@ 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+
// IMPORTANT: We do not synthesize/add any reasoning blocks from the top-level `reasoning` field.
151+
// The top-level `reasoning` is treated as display-only.
150152
const reasoningDetailsAccumulator = new Map<
151153
string,
152154
{
@@ -161,6 +163,15 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
161163
}
162164
>()
163165

166+
// Track whether we've seen reasoning_details in this stream
167+
// When reasoning_details are present, the top-level reasoning field is redundant
168+
// and should be ignored for storage (but may still be used for display)
169+
let hasReasoningDetails = false
170+
// Track whether we've yielded any displayable text from reasoning_details
171+
// This prevents double-display when both reasoning_details (with summary) and
172+
// top-level reasoning field contain the same text
173+
let hasYieldedReasoningFromDetails = false
174+
164175
for await (const chunk of stream) {
165176
const delta = chunk.choices[0]?.delta
166177
const finishReason = chunk.choices[0]?.finish_reason
@@ -183,6 +194,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
183194
}
184195

185196
if (deltaWithReasoning.reasoning_details && Array.isArray(deltaWithReasoning.reasoning_details)) {
197+
hasReasoningDetails = true
186198
for (const detail of deltaWithReasoning.reasoning_details) {
187199
const index = detail.index ?? 0
188200
// Use id as key when available to merge chunks that share the same reasoning block id
@@ -223,29 +235,47 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
223235
}
224236

225237
// Yield text for display (still fragmented for live streaming)
238+
// Only reasoning.text and reasoning.summary have displayable content
239+
// reasoning.encrypted is intentionally skipped as it contains redacted content
226240
let reasoningText: string | undefined
227241
if (detail.type === "reasoning.text" && typeof detail.text === "string") {
228242
reasoningText = detail.text
229243
} else if (detail.type === "reasoning.summary" && typeof detail.summary === "string") {
230244
reasoningText = detail.summary
231245
}
232-
// Note: reasoning.encrypted types are intentionally skipped as they contain redacted content
233246

234247
if (reasoningText) {
248+
hasYieldedReasoningFromDetails = true
235249
yield { type: "reasoning", text: reasoningText }
236250
}
237251
}
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,
252+
}
253+
254+
// Handle top-level reasoning field for UI display
255+
// IMPORTANT: The top-level `reasoning` field is ONLY for display purposes
256+
// when reasoning_details exists. It should NOT be stored or sent back - only
257+
// the original `reasoning_details` array received from the API should be stored.
258+
//
259+
// Display rules:
260+
// - If we've already yielded text from reasoning_details, DON'T yield top-level reasoning
261+
// (they contain the same text, would cause double display)
262+
// - If reasoning_details only has encrypted content (no displayable text), then yield
263+
// top-level reasoning for display
264+
// - If no reasoning_details exist, yield for display
265+
//
266+
// Storage rules:
267+
// - Never store top-level `reasoning` / `reasoning_content`. Only store what the API returns
268+
// in `reasoning_details`.
269+
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
270+
// Only yield for display if we haven't already yielded from reasoning_details
271+
if (!hasYieldedReasoningFromDetails) {
272+
yield { type: "reasoning", text: delta.reasoning }
243273
}
244274
} else if ("reasoning_content" in delta && typeof delta.reasoning_content === "string") {
245275
// Also check for reasoning_content for backward compatibility
246-
yield {
247-
type: "reasoning",
248-
text: delta.reasoning_content,
276+
// Same rules apply: yield for display only if not already yielded from details
277+
if (!hasYieldedReasoningFromDetails) {
278+
yield { type: "reasoning", text: delta.reasoning_content }
249279
}
250280
}
251281

@@ -282,7 +312,7 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
282312
}
283313
}
284314

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

0 commit comments

Comments
 (0)