From c1bdd91e7b9681e1056c4883b3e26cd0d416108b Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Tue, 21 Jul 2026 03:52:54 +0200 Subject: [PATCH] Hide internal reasoning replay placeholders (#7912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: hide internal reasoning replay placeholder * fix: hide internal reasoning replay placeholder in OpenAI→Claude translation too Mirror the isInternalReasoningPlaceholder() guard already applied to the Responses-API and OpenAI-Responses reasoning paths in openaiToClaudeResponse() (open-sse/translator/response/openai-to-claude.ts). The internal reasoning-replay sentinel was still leaking into the Claude "thinking" content block on this translation path. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test: close both-add merge of #7912 and #7905 reasoning/tool tests Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- open-sse/transformer/responsesTransformer.ts | 3 +- open-sse/translator/helpers/claudeHelper.ts | 16 ++++--- .../translator/response/openai-responses.ts | 4 +- .../translator/response/openai-to-claude.ts | 3 +- open-sse/utils/reasoningPlaceholder.ts | 10 +++++ tests/unit/responses-transformer.test.ts | 15 +++++++ .../unit/responses-translation-fixes.test.ts | 44 +++++++++++++++++++ .../translator-resp-openai-to-claude.test.ts | 35 +++++++++++++++ 8 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 open-sse/utils/reasoningPlaceholder.ts diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index 1255f5a82b..ff9e86dfd8 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -1,5 +1,6 @@ import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts"; import { shouldParseTextualReasoningTags } from "../handlers/responseSanitizer.ts"; +import { isInternalReasoningPlaceholder } from "../utils/reasoningPlaceholder.ts"; import * as fs from "fs"; import * as path from "path"; /** @@ -525,7 +526,7 @@ export function createResponsesApiTransformStream( } // Handle reasoning_content (OpenAI native format) - if (delta.reasoning_content) { + if (delta.reasoning_content && !isInternalReasoningPlaceholder(delta.reasoning_content)) { startReasoning(controller, idx); emitReasoningDelta(controller, delta.reasoning_content); } diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 20a91b0931..446cea4828 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -2,6 +2,9 @@ import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { lookupReasoning, recordReplay } from "../../services/reasoningCache.ts"; import { getModelTargetFormat } from "../../config/providerModels.ts"; +import { NON_ANTHROPIC_THINKING_PLACEHOLDER } from "../../utils/reasoningPlaceholder.ts"; + +export { NON_ANTHROPIC_THINKING_PLACEHOLDER } from "../../utils/reasoningPlaceholder.ts"; // MiniMax exposes a Claude-compatible endpoint but rejects Anthropic's extended // `output_config` parameter (used to steer reasoning effort and structured output) @@ -9,10 +12,7 @@ import { getModelTargetFormat } from "../../config/providerModels.ts"; // dispatching Claude-shape requests to these providers. Anthropic Claude and // other Claude-compatible upstreams that do accept it are unaffected. // Ported from upstream decolua/9router#820 by @hiepau1231. -const CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG = new Set([ - "minimax", - "minimax-cn", -]); +const CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG = new Set(["minimax", "minimax-cn"]); // Placeholder thinking text used as last-resort fallback when: // - Target upstream is a non-Anthropic Claude-shape provider @@ -21,8 +21,6 @@ const CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG = new Set([ // - reasoningCache has no entry for the corresponding tool_use.id // Must be non-empty: kimi-coding treats empty `thinking.thinking` as // `reasoning_content missing` and 400s. -export const NON_ANTHROPIC_THINKING_PLACEHOLDER = "(prior reasoning summary unavailable)"; - type ClaudeContentBlock = { type?: string; text?: string; @@ -468,7 +466,11 @@ export function prepareClaudeRequest( ? typeof b.data === "string" && (b.data as string).length > 0 : typeof b.signature === "string" && b.signature.length > 0 ); - if (latestHasExistingThinking && supportsRedactedThinking && latestHasGenuineThinkingSignature) { + if ( + latestHasExistingThinking && + supportsRedactedThinking && + latestHasGenuineThinkingSignature + ) { // Anthropic: skip all thinking-block rewrites entirely — the // blocks must remain verbatim (type, thinking, signature, data). continue; diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 0884d6d6d7..627d6671c8 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -7,6 +7,7 @@ import { FORMATS } from "../formats.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; import { fallbackToolCallId } from "../helpers/toolCallHelper.ts"; import { shouldParseTextualReasoningTags } from "../../handlers/responseSanitizer.ts"; +import { isInternalReasoningPlaceholder } from "../../utils/reasoningPlaceholder.ts"; import { normalizeToolName, stripEmptyOptionalToolArgs, @@ -162,7 +163,7 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { }); } - if (delta.reasoning_content) { + if (delta.reasoning_content && !isInternalReasoningPlaceholder(delta.reasoning_content)) { startReasoning(state, emit, idx); emitReasoningDelta(state, emit, delta.reasoning_content); } @@ -646,6 +647,7 @@ function markResponsesReasoningDeltaEmitted(state, itemId) { // its thinking panel (`reasoning_content`, or `reasoning_text` for Copilot-compatible // clients). Mirrors the `response.reasoning_summary_text.delta` branch. function buildResponsesReasoningDeltaChunk(state, text) { + if (isInternalReasoningPlaceholder(text)) return null; const delta = state.copilotCompatibleReasoning ? { reasoning_text: text } : { reasoning_content: text }; diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index a2f77461af..c7c9cac3b9 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -4,6 +4,7 @@ import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts"; import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; import { isAbortFinishReason } from "../../utils/finishReason.ts"; +import { isInternalReasoningPlaceholder } from "../../utils/reasoningPlaceholder.ts"; // Helper: stop thinking block if started function stopThinkingBlock(state, results) { @@ -108,7 +109,7 @@ export function openaiToClaudeResponse(chunk, state) { } if (parts.length > 0) reasoningContent = parts.join(""); } - if (reasoningContent) { + if (reasoningContent && !isInternalReasoningPlaceholder(reasoningContent)) { stopTextBlock(state, results); if (!state.thinkingBlockStarted) { diff --git a/open-sse/utils/reasoningPlaceholder.ts b/open-sse/utils/reasoningPlaceholder.ts new file mode 100644 index 0000000000..d3669ba2b6 --- /dev/null +++ b/open-sse/utils/reasoningPlaceholder.ts @@ -0,0 +1,10 @@ +/** + * Internal replay sentinel used when an upstream requires non-empty reasoning content but the + * original reasoning summary is unavailable. It is valid request scaffolding, never user-visible + * reasoning, so response translators must suppress it before emitting client-facing events. + */ +export const NON_ANTHROPIC_THINKING_PLACEHOLDER = "(prior reasoning summary unavailable)"; + +export function isInternalReasoningPlaceholder(value: unknown): boolean { + return typeof value === "string" && value.trim() === NON_ANTHROPIC_THINKING_PLACEHOLDER; +} diff --git a/tests/unit/responses-transformer.test.ts b/tests/unit/responses-transformer.test.ts index 792d5f7300..68670d4095 100644 --- a/tests/unit/responses-transformer.test.ts +++ b/tests/unit/responses-transformer.test.ts @@ -175,6 +175,21 @@ test("createResponsesApiTransformStream handles native reasoning content and too ); }); +test("createResponsesApiTransformStream hides the internal reasoning replay placeholder", async () => { + const output = await runTransformStream([ + 'data: {"choices":[{"index":0,"delta":{"reasoning_content":"(prior reasoning summary unavailable)"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{"content":"Visible answer"},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + ]); + + const events = parseSseOutput(output); + assert.equal( + events.some((event) => event.event === "response.reasoning_summary_text.delta"), + false + ); + assert.equal(output.includes("prior reasoning summary unavailable"), false); + assert.equal(output.includes("Visible answer"), true); +}); test("createResponsesApiTransformStream restores declared custom tools without changing functions", async () => { const output = await runTransformStream( [ diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index 1d8c478de1..0d859f7c02 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -544,6 +544,50 @@ test("Responses→Chat streaming: reasoning delta emits reasoning_content in Cha assert.equal(result.choices[0].delta.reasoning_content, "thinking step..."); }); +test("Responses→Chat streaming: internal reasoning replay placeholder stays hidden", () => { + const state = { + started: false, + chatId: null, + created: null, + toolCallIndex: 0, + finishReasonSent: false, + }; + + const result = openaiResponsesToOpenAIResponse( + { + type: "response.reasoning_summary_text.delta", + delta: "(prior reasoning summary unavailable)", + item_id: "rs_1", + output_index: 0, + summary_index: 0, + }, + state + ); + + assert.equal(result, null); +}); + +test("Chat→Responses streaming: internal reasoning replay placeholder stays hidden", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + const events = openaiToOpenAIResponsesResponse( + { + choices: [ + { + index: 0, + delta: { reasoning_content: "(prior reasoning summary unavailable)" }, + finish_reason: null, + }, + ], + }, + state + ); + + assert.equal( + events.some((event) => event.event === "response.reasoning_summary_text.delta"), + false + ); +}); + test("Responses→Chat streaming: Copilot mode emits reasoning_text for summary deltas", () => { const state = { started: false, diff --git a/tests/unit/translator-resp-openai-to-claude.test.ts b/tests/unit/translator-resp-openai-to-claude.test.ts index b5a7f1841c..04f4c4fd62 100644 --- a/tests/unit/translator-resp-openai-to-claude.test.ts +++ b/tests/unit/translator-resp-openai-to-claude.test.ts @@ -76,6 +76,41 @@ test("OpenAI stream: reasoning_content closes before text content starts", () => assert.equal(result[5].delta.text, "Answer"); }); +test("OpenAI stream: internal reasoning replay placeholder stays hidden from Claude thinking block", () => { + const state = createState(); + const placeholder = openaiToClaudeResponse( + { + id: "chatcmpl-2b", + model: "gpt-4.1", + choices: [ + { + index: 0, + delta: { reasoning_content: "(prior reasoning summary unavailable)" }, + finish_reason: null, + }, + ], + }, + state + ); + const text = openaiToClaudeResponse( + { + id: "chatcmpl-2b", + model: "gpt-4.1", + choices: [{ index: 0, delta: { content: "Answer" }, finish_reason: null }], + }, + state + ); + const result = flatten([placeholder, text]); + + assert.equal( + result.some((event) => event.type === "content_block_start" && event.content_block?.type === "thinking"), + false + ); + assert.equal(result[0].type, "message_start"); + assert.equal(result[1].content_block.type, "text"); + assert.equal(result[2].delta.text, "Answer"); +}); + test("OpenAI stream: tool calls strip Claude OAuth prefix and keep cache usage", () => { const state = createState(); const started = openaiToClaudeResponse(