From 583d3ebe1d96111daad68ca5b9c19815cd24227d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:22:49 -0300 Subject: [PATCH] fix(providers): read reasoning_text in Claude-format response translator (#7856) (#7919) --- .../fixes/7856-copilot-reasoning-text.md | 1 + open-sse/handlers/responseTranslator.ts | 27 ++++-------- ...6-copilot-reasoning-text-nonstream.test.ts | 44 +++++++++++++++++++ 3 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 changelog.d/fixes/7856-copilot-reasoning-text.md create mode 100644 tests/unit/issue-7856-copilot-reasoning-text-nonstream.test.ts diff --git a/changelog.d/fixes/7856-copilot-reasoning-text.md b/changelog.d/fixes/7856-copilot-reasoning-text.md new file mode 100644 index 0000000000..69ba5613fc --- /dev/null +++ b/changelog.d/fixes/7856-copilot-reasoning-text.md @@ -0,0 +1 @@ +- fix(providers): Copilot reasoning_text now surfaces in Claude-format non-stream responses, fixing spurious 502s (#7856) diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 12f384c6b1..92fb47b7b6 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -5,6 +5,7 @@ import { } from "../services/geminiThoughtSignatureStore.ts"; import { normalizeOpenAICompatibleFinishReasonString } from "../utils/finishReason.ts"; import { containsTextualToolCallMarker } from "../utils/textualToolCall.ts"; +import { getAnyReasoningValue } from "../utils/reasoningFields.ts"; type JsonRecord = Record; @@ -576,27 +577,15 @@ export function translateNonStreamingResponse( /** * 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. + * Delegates to the shared reasoning-field resolver (`open-sse/utils/reasoningFields.ts`) + * so every reasoning alias — DeepSeek-style `reasoning_content`, the OpenRouter/StepFun + * `reasoning` string, GitHub Copilot's `reasoning_text`, `thinking`/`thought`, and + * `reasoning_details[]` (array of { text | content }) — is read from one place instead + * of a divergent local copy. Mirrors 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 ""; + return getAnyReasoningValue(messageObj); } /** diff --git a/tests/unit/issue-7856-copilot-reasoning-text-nonstream.test.ts b/tests/unit/issue-7856-copilot-reasoning-text-nonstream.test.ts new file mode 100644 index 0000000000..d4677880ed --- /dev/null +++ b/tests/unit/issue-7856-copilot-reasoning-text-nonstream.test.ts @@ -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"; + +// GitHub Copilot (github/gemini-3.x models) returns reasoning in `message.reasoning_text`, +// with `content` left empty. Before the fix, resolveReasoningText() in responseTranslator.ts +// only checked reasoning_content / reasoning / reasoning_details[] and missed reasoning_text, +// so the Claude-format translation dropped the reasoning entirely and emitted a bare +// "(empty response)" text block, which detectMalformedNonStream() then rejects (-> 502). +const copilotStyleResponse = { + id: "chatcmpl-copilot-7856", + object: "chat.completion", + created: 1783700000, + model: "github/gemini-3-pro", + choices: [ + { + index: 0, + finish_reason: "stop", + message: { + role: "assistant", + content: "", + reasoning_text: "Let me think about the user's request before answering.", + }, + }, + ], + usage: { prompt_tokens: 12, completion_tokens: 8, total_tokens: 20 }, +}; + +test("#7856 /v1/messages non-stream translation surfaces Copilot reasoning_text as a thinking block", () => { + const translated = translateNonStreamingResponse(copilotStyleResponse, "openai", "claude", null) as { + content: Array<{ type: string; thinking?: string; text?: string }>; + }; + + const thinkingBlock = translated.content.find((block) => block.type === "thinking"); + assert.ok(thinkingBlock, "expected a thinking block carrying the Copilot reasoning_text"); + assert.equal(thinkingBlock?.thinking, "Let me think about the user's request before answering."); +}); + +test("#7856 /v1/messages non-stream translation of a Copilot reasoning_text turn is not flagged malformed", () => { + const translated = translateNonStreamingResponse(copilotStyleResponse, "openai", "claude", null); + const malformedReason = detectMalformedNonStream(translated); + assert.equal(malformedReason, null); +});