Skip to content

Commit e25c7ef

Browse files
committed
fix: preserve reasoning_details shape to prevent malformed responses
Follow-up to #10285. - Preserve the original reasoning_details array structure from the API to prevent malformed responses when sending back to providers - Strip 'id' field from openai-responses-v1 blocks because OpenAI's Responses API requires 'store: true' to persist reasoning blocks (we manage conversation state client-side) - 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 e25c7ef

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
@@ -361,7 +361,8 @@ 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+
// We preserve the original shape of reasoning_details to prevent malformed responses.
365366
const reasoningDetailsAccumulator = new Map<
366367
string,
367368
{
@@ -376,6 +377,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
376377
}
377378
>()
378379

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

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

449456
if (reasoningText) {
457+
hasYieldedReasoningFromDetails = true
450458
yield { type: "reasoning", text: reasoningText }
451459
}
452460
}
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 }
461+
}
462+
463+
// Handle top-level reasoning field for UI display.
464+
// Skip if we've already yielded from reasoning_details to avoid duplicate display.
465+
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
466+
if (!hasYieldedReasoningFromDetails) {
467+
yield { type: "reasoning", text: delta.reasoning }
468+
}
457469
}
458470

459471
// Emit raw tool call chunks - NativeToolCallParser handles state management
@@ -488,7 +500,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
488500
}
489501
}
490502

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

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)