Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/fixes/6623-6623-mimo-502-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(api): recognize OpenRouter reasoning/reasoning_details in non-streaming OpenAI-to-Claude conversion (#6623)
30 changes: 28 additions & 2 deletions open-sse/handlers/responseTranslator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,31 @@ export function translateNonStreamingResponse(
return intermediateOpenAI;
}

/**
* Resolve reasoning/thinking text off a non-streaming OpenAI-format message object.
* Checks DeepSeek-style `reasoning_content`, then the OpenRouter/StepFun aliases
* `reasoning` and `reasoning_details[]` (array of { text | content }), mirroring the
* streaming translator's fallback chain in open-sse/translator/response/openai-to-claude.ts.
*/
function resolveReasoningText(messageObj: JsonRecord): string {
if (messageObj.reasoning_content) {
return toString(messageObj.reasoning_content);
}
if (typeof messageObj.reasoning === "string" && messageObj.reasoning) {
return messageObj.reasoning;
}
if (Array.isArray(messageObj.reasoning_details)) {
const parts: string[] = [];
for (const detail of messageObj.reasoning_details) {
const detailObj = toRecord(detail);
const text = detailObj.text ?? detailObj.content;
if (typeof text === "string" && text) parts.push(text);
}
return parts.join("");
}
return "";
}
Comment on lines +569 to +586

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.

medium

The current implementation of resolveReasoningText has a potential robustness issue. If messageObj.reasoning_content is a truthy non-string value (such as a number or an object), if (messageObj.reasoning_content) will evaluate to true, but toString will return "". This causes the function to return "" immediately, preventing the fallback to reasoning or reasoning_details from being evaluated.

We can make this more robust and consistent by using the existing toString helper to safely extract and validate the string values before checking their truthiness. This also simplifies the type checks and null/undefined guards for reasoning and the individual details.

function resolveReasoningText(messageObj: JsonRecord): string {
  const reasoningContent = toString(messageObj.reasoning_content);
  if (reasoningContent) {
    return reasoningContent;
  }
  const reasoning = toString(messageObj.reasoning);
  if (reasoning) {
    return reasoning;
  }
  if (Array.isArray(messageObj.reasoning_details)) {
    const parts: string[] = [];
    for (const detail of messageObj.reasoning_details) {
      const detailObj = toRecord(detail);
      const text = toString(detailObj.text ?? detailObj.content);
      if (text) parts.push(text);
    }
    return parts.join("");
  }
  return "";
}


/**
* Helper to convert an OpenAI chat.completion JSON object to Claude format for non-streaming.
*/
Expand All @@ -578,11 +603,12 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco

let hasTextOrReasoning = false;

if (messageObj.reasoning_content) {
const reasoningText = resolveReasoningText(messageObj);
if (reasoningText) {
hasTextOrReasoning = true;
content.push({
type: "thinking",
thinking: toString(messageObj.reasoning_content),
thinking: reasoningText,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
import { detectMalformedNonStream } from "../../open-sse/utils/diagnostics.ts";
import { isEmptyContentResponse } from "../../open-sse/services/errorClassifier.ts";

const mimoOpenRouterStyleResponse = {
id: "gen-1783636289-lJcRXwMde7qjgDJfHiBC",
object: "chat.completion",
created: 1783636289,
model: "mimo-v2.5-free",
choices: [
{
index: 0,
finish_reason: "length",
logprobs: null,
message: {
role: "assistant",
content: null,
refusal: null,
reasoning: "Hmm, the user just said hi",
reasoning_details: [
{ type: "reasoning.text", text: "Hmm, the user just said hi", format: "unknown", index: 0 },
],
},
},
],
usage: { prompt_tokens: 248, completion_tokens: 10, total_tokens: 258 },
};

test("#6623 raw responseBody is not flagged empty by isEmptyContentResponse", () => {
assert.equal(isEmptyContentResponse(mimoOpenRouterStyleResponse), false);
});

test("#6623 /v1/messages non-stream translation of an OpenRouter reasoning-only turn is flagged malformed (502) - RED", () => {
const translated = translateNonStreamingResponse(mimoOpenRouterStyleResponse, "openai", "claude", null);
const malformedReason = detectMalformedNonStream(translated);
assert.equal(malformedReason, null);
});

test("#6623 /v1/chat/completions (openai->openai, no translation) is unaffected", () => {
const passthrough = translateNonStreamingResponse(mimoOpenRouterStyleResponse, "openai", "openai", null);
assert.equal(passthrough, mimoOpenRouterStyleResponse);
});
Loading