mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
fix(stream): suppress </think> close marker for Responses API clients (#7747)
* fix(stream): suppress `</think>` close marker for Responses API clients The Claude→OpenAI `</think>` close marker (#4633) exists for Chat Completions clients that scan content for the marker (Claude Code / Cursor). On the openai-responses path the responsesTransformer already maps reasoning_content to structured reasoning items, so the marker has no consumer and leaks verbatim into response.output_text.delta — observed in production with kimi-coding (k3): thinking renders correctly while a stray `</think>` sits at the start of the assistant text (up to 6 consecutive markers when the upstream also emits stray close-tag text deltas). resolveSuppressThinkClose() gains a clientResponseFormat option that always suppresses the marker for openai-responses, winning over both the UA allowlist and an explicit keep header (no legitimate marker consumer exists in the Responses format). chatCore passes the format through, and ExecuteInput now carries clientResponseFormat so the two executors that do their own Claude→OpenAI translation apply the same policy: GLM's Anthropic transport and zed-hosted's Anthropic backend (which previously applied no suppression at all, not even the #5245 UA/header policy). Chat Completions behavior is unchanged (#4633 / #5123 / #5245 / #5312). * refactor(executors): extract helpers to keep execute/executeTransport under the complexity cap 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: xz-dev <xz-dev@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
1
changelog.d/fixes/responses-think-close-marker.md
Normal file
1
changelog.d/fixes/responses-think-close-marker.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(stream):** Responses API clients (`/v1/responses`) no longer receive a stray `</think>` text delta at the start of the assistant message on Claude-format upstreams (observed with kimi-coding). The `</think>` close marker (#4633) exists for Chat Completions clients that scan content for it; Responses API clients receive reasoning as structured reasoning items, so the marker is now always suppressed on that path — including the GLM and zed-hosted executors, which do their own Claude→OpenAI translation.
|
||||
@@ -169,6 +169,11 @@ export type ExecuteInput = {
|
||||
upstreamExtraHeaders?: Record<string, string> | null;
|
||||
/** Original client request headers (read-only). Executors may forward select headers upstream. */
|
||||
clientHeaders?: Record<string, string> | null;
|
||||
/** Response format the end client expects (e.g. "openai-responses"). Executors
|
||||
* that do their own Claude→OpenAI stream translation (GLM, zed-hosted) use
|
||||
* this to apply client-format-aware policies such as `</think>` close-marker
|
||||
* suppression. */
|
||||
clientResponseFormat?: string | null;
|
||||
/** Callback to persist tokens that are proactively refreshed during execution.
|
||||
* Accepts a partial credentials patch (e.g. `{ accessToken, refreshToken }` or
|
||||
* `{ testStatus: "expired", isActive: false }`); the caller merges into the
|
||||
|
||||
@@ -424,33 +424,7 @@ export class GlmExecutor extends DefaultExecutor {
|
||||
const result = { response, url, headers, transformedBody };
|
||||
|
||||
if (transport === "anthropic") {
|
||||
// Resolve whether the `</think>` close marker should be suppressed for
|
||||
// this client. GLM's Anthropic transport does its own Claude→OpenAI
|
||||
// translation (bypassing chatCore's stream), so we must resolve the flag
|
||||
// here from the original client headers (#5245 / #5312).
|
||||
const clientHeaders = input.clientHeaders ?? {};
|
||||
const suppressThinkClose = resolveSuppressThinkClose({
|
||||
userAgent: clientHeaders["user-agent"] ?? clientHeaders["User-Agent"] ?? null,
|
||||
thinkingMarkerHeader:
|
||||
clientHeaders[THINKING_MARKER_HEADER] ??
|
||||
clientHeaders["x-omniroute-thinking-marker"] ??
|
||||
null,
|
||||
});
|
||||
|
||||
const translatedResponse =
|
||||
input.stream && result.response.ok
|
||||
? translateSseResponse(result.response, this.provider, input.model, suppressThinkClose)
|
||||
: isJsonResponse(result.response)
|
||||
? await translateAnthropicJsonResponse(result.response)
|
||||
: result.response;
|
||||
return {
|
||||
...result,
|
||||
response: translatedResponse,
|
||||
url,
|
||||
headers,
|
||||
transformedBody,
|
||||
targetFormat: FORMATS.OPENAI,
|
||||
};
|
||||
return this.finalizeAnthropicTransportResult(input, result);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -462,6 +436,44 @@ export class GlmExecutor extends DefaultExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GLM's Anthropic transport does its own Claude→OpenAI translation
|
||||
* (bypassing chatCore's stream), so the `</think>` close-marker
|
||||
* suppression flag and the response translation both have to be resolved
|
||||
* here from the original client headers (#5245 / #5312). Extracted from
|
||||
* `executeTransport` to keep that method's cyclomatic complexity under the
|
||||
* project cap.
|
||||
*/
|
||||
private async finalizeAnthropicTransportResult(
|
||||
input: ExecuteInput,
|
||||
result: { response: Response; url: string; headers: Record<string, string>; transformedBody: unknown }
|
||||
): Promise<GlmExecuteResult> {
|
||||
const { response: rawResponse, url, headers, transformedBody } = result;
|
||||
const clientHeaders = input.clientHeaders ?? {};
|
||||
const suppressThinkClose = resolveSuppressThinkClose({
|
||||
userAgent: clientHeaders["user-agent"] ?? clientHeaders["User-Agent"] ?? null,
|
||||
thinkingMarkerHeader:
|
||||
clientHeaders[THINKING_MARKER_HEADER] ??
|
||||
clientHeaders["x-omniroute-thinking-marker"] ??
|
||||
null,
|
||||
clientResponseFormat: input.clientResponseFormat ?? null,
|
||||
});
|
||||
|
||||
const translatedResponse =
|
||||
input.stream && rawResponse.ok
|
||||
? translateSseResponse(rawResponse, this.provider, input.model, suppressThinkClose)
|
||||
: isJsonResponse(rawResponse)
|
||||
? await translateAnthropicJsonResponse(rawResponse)
|
||||
: rawResponse;
|
||||
return {
|
||||
response: translatedResponse,
|
||||
url,
|
||||
headers,
|
||||
transformedBody,
|
||||
targetFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<GlmExecuteResult> {
|
||||
const effortTier = parseGlm52Effort(input.model);
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import { claudeToOpenAIResponse } from "../translator/response/claude-to-openai.
|
||||
import { geminiToOpenAIResponse } from "../translator/response/gemini-to-openai.ts";
|
||||
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.ts";
|
||||
import { ZED_HEADERS, resolveZedModels, zedLlmFetch, type ZedCredentials } from "../shared/zedAuth.ts";
|
||||
import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts";
|
||||
|
||||
const ZED_PROVIDER = {
|
||||
anthropic: "Anthropic",
|
||||
@@ -162,16 +163,42 @@ function normalizeStatus(status: unknown): Record<string, unknown> | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `</think>` close-marker suppression from the incoming client
|
||||
* headers / response format, extracted from `ZedHostedExecutor.execute` to
|
||||
* keep that method's cyclomatic complexity under the project cap.
|
||||
*/
|
||||
function resolveZedSuppressThinkClose(
|
||||
clientHeaders: ExecuteInput["clientHeaders"],
|
||||
clientResponseFormat: ExecuteInput["clientResponseFormat"]
|
||||
): boolean {
|
||||
return resolveSuppressThinkClose({
|
||||
userAgent: clientHeaders?.["user-agent"] ?? clientHeaders?.["User-Agent"] ?? null,
|
||||
thinkingMarkerHeader:
|
||||
clientHeaders?.[THINKING_MARKER_HEADER] ??
|
||||
clientHeaders?.["x-omniroute-thinking-marker"] ??
|
||||
null,
|
||||
clientResponseFormat: clientResponseFormat ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function wrapZedCompletionStream(
|
||||
response: Response,
|
||||
provider: ZedProviderName,
|
||||
model: string
|
||||
model: string,
|
||||
options?: { suppressThinkClose?: boolean }
|
||||
): Response {
|
||||
if (!response.ok || !response.body) return response;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
const state = initProviderState(provider, model);
|
||||
if (options?.suppressThinkClose) {
|
||||
// Responses API clients (and UA/header-opted-out clients) must not see the
|
||||
// textual `</think>` close marker — same policy chatCore applies (#4633 /
|
||||
// #5245 / kimi-coding stray marker on /v1/responses).
|
||||
state.suppressThinkClose = true;
|
||||
}
|
||||
let buffer = "";
|
||||
let done = false;
|
||||
|
||||
@@ -272,7 +299,16 @@ export class ZedHostedExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput): Promise<{
|
||||
async execute({
|
||||
model,
|
||||
body,
|
||||
stream,
|
||||
credentials,
|
||||
signal,
|
||||
log,
|
||||
clientHeaders,
|
||||
clientResponseFormat,
|
||||
}: ExecuteInput): Promise<{
|
||||
response: Response;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
@@ -307,7 +343,15 @@ export class ZedHostedExecutor extends BaseExecutor {
|
||||
},
|
||||
});
|
||||
|
||||
const wrapped = response.ok ? wrapZedCompletionStream(response, provider, model) : response;
|
||||
// The Anthropic backend converts Claude events to OpenAI chunks inside
|
||||
// wrapZedCompletionStream, bypassing chatCore's marker policy — resolve
|
||||
// `</think>` close-marker suppression here from the client format /
|
||||
// headers (same policy as chatCore / GLM, #5245 / kimi-coding leak).
|
||||
const suppressThinkClose = resolveZedSuppressThinkClose(clientHeaders, clientResponseFormat);
|
||||
|
||||
const wrapped = response.ok
|
||||
? wrapZedCompletionStream(response, provider, model, { suppressThinkClose })
|
||||
: response;
|
||||
return {
|
||||
response: wrapped,
|
||||
url: `${(this.config as Record<string, unknown>)?.llmBaseUrl || "https://cloud.zed.dev"}/completions`,
|
||||
|
||||
@@ -2566,6 +2566,7 @@ export async function handleChatCore({
|
||||
clientRawRequest?.headers,
|
||||
userAgent
|
||||
),
|
||||
clientResponseFormat,
|
||||
onCredentialsRefreshed,
|
||||
skipUpstreamRetry,
|
||||
contextEditing: { enabled: contextEditingEnabled },
|
||||
@@ -2812,6 +2813,7 @@ export async function handleChatCore({
|
||||
clientRawRequest?.headers,
|
||||
userAgent
|
||||
),
|
||||
clientResponseFormat,
|
||||
onCredentialsRefreshed,
|
||||
skipUpstreamRetry,
|
||||
contextEditing: { enabled: contextEditingEnabled },
|
||||
@@ -3307,6 +3309,7 @@ export async function handleChatCore({
|
||||
extendedContext,
|
||||
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
|
||||
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
|
||||
clientResponseFormat,
|
||||
onCredentialsRefreshed,
|
||||
skipUpstreamRetry: isCombo,
|
||||
contextEditing: { enabled: contextEditingEnabled },
|
||||
@@ -4649,10 +4652,13 @@ export async function handleChatCore({
|
||||
// Suppress the `</think>` close marker for clients that render it verbatim
|
||||
// (e.g. OpenCode by UA; any client via `x-omniroute-thinking-marker: off`);
|
||||
// preserved for Claude Code / Cursor and unknown clients by default (#5245 /
|
||||
// #5312). The header wins over the UA allowlist.
|
||||
// #5312). Responses API clients always suppress it (structured reasoning
|
||||
// items make the marker meaningless); otherwise the header wins over the
|
||||
// UA allowlist.
|
||||
resolveSuppressThinkClose({
|
||||
userAgent: streamUserAgent,
|
||||
thinkingMarkerHeader,
|
||||
clientResponseFormat,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -21,8 +21,16 @@
|
||||
* which suppresses the marker regardless of User-Agent. `on` forces it kept
|
||||
* (overriding the UA allowlist). The default (header absent) is byte-identical
|
||||
* to the UA-only policy, so #4633 / #5123 are never regressed.
|
||||
*
|
||||
* Responses API clients (`openai-responses`) are always suppressed: the
|
||||
* Responses transformer maps `reasoning_content` to structured reasoning items
|
||||
* natively, so no consumer on that path scans content for the marker — it can
|
||||
* only leak verbatim into `response.output_text.delta` (observed with
|
||||
* kimi-coding: a stray `</think>` at the start of the assistant text).
|
||||
*/
|
||||
|
||||
import { FORMATS } from "../translator/formats.ts";
|
||||
|
||||
/** Header clients send to explicitly opt in/out of the `</think>` close marker. */
|
||||
export const THINKING_MARKER_HEADER = "x-omniroute-thinking-marker";
|
||||
|
||||
@@ -70,7 +78,13 @@ export function thinkingMarkerHeaderSignal(
|
||||
export function resolveSuppressThinkClose(opts: {
|
||||
userAgent?: string | null;
|
||||
thinkingMarkerHeader?: string | null;
|
||||
clientResponseFormat?: string | null;
|
||||
}): boolean {
|
||||
// The marker only exists for Chat Completions clients that scan content for
|
||||
// it; Responses API clients receive reasoning as structured items instead.
|
||||
// This wins over the UA allowlist AND the explicit header: there is no
|
||||
// legitimate marker consumer in the Responses format.
|
||||
if (opts.clientResponseFormat === FORMATS.OPENAI_RESPONSES) return true;
|
||||
const headerSignal = thinkingMarkerHeaderSignal(opts.thinkingMarkerHeader);
|
||||
if (headerSignal !== null) return headerSignal;
|
||||
return shouldSuppressThinkCloseMarker(opts.userAgent);
|
||||
|
||||
58
tests/unit/think-close-marker-responses-format.test.ts
Normal file
58
tests/unit/think-close-marker-responses-format.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { resolveSuppressThinkClose } = await import("../../open-sse/utils/thinkCloseMarker.ts");
|
||||
|
||||
// kimi-coding via /v1/responses: the Claude→OpenAI `</think>` close marker
|
||||
// (#4633) exists for Chat Completions clients that scan content for the marker
|
||||
// (Claude Code / Cursor). Responses API clients receive reasoning as
|
||||
// structured reasoning items (responsesTransformer maps reasoning_content
|
||||
// natively), so the textual marker has no consumer on this path and always
|
||||
// leaks verbatim into `response.output_text.delta`.
|
||||
|
||||
test("openai-responses client format always suppresses the close marker", () => {
|
||||
assert.equal(
|
||||
resolveSuppressThinkClose({
|
||||
userAgent: "OpenAI/JS 6.26.0",
|
||||
thinkingMarkerHeader: null,
|
||||
clientResponseFormat: FORMATS.OPENAI_RESPONSES,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("openai-responses suppression wins over an explicit keep header", () => {
|
||||
// There is no legitimate marker consumer in the Responses API format; an
|
||||
// explicit `x-omniroute-thinking-marker: on` would only re-create the leak.
|
||||
assert.equal(
|
||||
resolveSuppressThinkClose({
|
||||
userAgent: null,
|
||||
thinkingMarkerHeader: "on",
|
||||
clientResponseFormat: FORMATS.OPENAI_RESPONSES,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("openai chat format keeps the conservative default (marker on)", () => {
|
||||
assert.equal(
|
||||
resolveSuppressThinkClose({
|
||||
userAgent: "OpenAI/JS 6.26.0",
|
||||
thinkingMarkerHeader: null,
|
||||
clientResponseFormat: FORMATS.OPENAI,
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("absent client format preserves the UA/header policy", () => {
|
||||
assert.equal(
|
||||
resolveSuppressThinkClose({ userAgent: "opencode/1.0", thinkingMarkerHeader: null }),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
resolveSuppressThinkClose({ userAgent: "unknown-client", thinkingMarkerHeader: null }),
|
||||
false
|
||||
);
|
||||
});
|
||||
70
tests/unit/zed-hosted-think-close-marker.test.ts
Normal file
70
tests/unit/zed-hosted-think-close-marker.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { __test__ } = await import("../../open-sse/executors/zed-hosted.ts");
|
||||
const { wrapZedCompletionStream } = __test__;
|
||||
|
||||
// zed-hosted's Anthropic backend converts Claude events to OpenAI chunks
|
||||
// inside the executor (wrapZedCompletionStream → claudeToOpenAIResponse),
|
||||
// bypassing chatCore's suppressThinkClose wiring. Responses API clients
|
||||
// receive reasoning as structured items, so the textual `</think>` close
|
||||
// marker must be suppressed on that path (same policy as chatCore / GLM).
|
||||
|
||||
function buildZedAnthropicNdjson(): string {
|
||||
const lines = [
|
||||
{ event: { type: "message_start", message: { id: "msg_zed", model: "claude-test" } } },
|
||||
{ event: { type: "content_block_start", index: 0, content_block: { type: "thinking" } } },
|
||||
{
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "thinking_delta", thinking: "plan" },
|
||||
},
|
||||
},
|
||||
{ event: { type: "content_block_stop", index: 0 } },
|
||||
{ event: { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } } },
|
||||
{
|
||||
event: { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hi" } },
|
||||
},
|
||||
{ event: { type: "content_block_stop", index: 1 } },
|
||||
{
|
||||
event: {
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: { output_tokens: 3 },
|
||||
},
|
||||
},
|
||||
{ event: { type: "message_stop" } },
|
||||
];
|
||||
return lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
||||
}
|
||||
|
||||
async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let out = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
out += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wrapAnthropic(options?: Record<string, unknown>): Promise<string> {
|
||||
const response = new Response(buildZedAnthropicNdjson(), { status: 200 });
|
||||
const wrapped = wrapZedCompletionStream(response, "Anthropic", "claude-test", options);
|
||||
return readAll(wrapped.body as ReadableStream<Uint8Array>);
|
||||
}
|
||||
|
||||
test("zed anthropic stream keeps the close marker by default (#4633)", async () => {
|
||||
const out = await wrapAnthropic();
|
||||
assert.ok(out.includes('"content":"</think>"'), "expected default marker emission");
|
||||
});
|
||||
|
||||
test("zed anthropic stream suppresses the close marker when asked", async () => {
|
||||
const out = await wrapAnthropic({ suppressThinkClose: true });
|
||||
assert.ok(!out.includes("</think>"), "marker must not leak into output");
|
||||
assert.ok(out.includes('"content":"Hi"'), "text content still flows");
|
||||
assert.ok(out.includes('"reasoning_content":"plan"'), "reasoning still flows");
|
||||
});
|
||||
Reference in New Issue
Block a user