Hide internal reasoning replay placeholders (#7912)

* 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 <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Jan Leon
2026-07-21 03:52:54 +02:00
committed by GitHub
parent 1175746d4f
commit c1bdd91e7b
8 changed files with 120 additions and 10 deletions

View File

@@ -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);
}

View File

@@ -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<string>([
"minimax",
"minimax-cn",
]);
const CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG = new Set<string>(["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<string>([
// - 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;

View File

@@ -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 };

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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(
[

View File

@@ -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,

View File

@@ -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(