Files
OmniRoute/open-sse/utils/thinkCloseMarker.ts
Prudhvi Vuda 14f4c67598 fix(sse): suppress </think> by default on Chat Completions (#8245) (#8309)
Claude→OpenAI translation was emitting a literal </think> into
delta.content for ordinary Chat Completions clients. Reasoning already
ships as reasoning_content, so default to suppress and keep
x-omniroute-thinking-marker: on as the #4633 opt-in.
2026-07-24 09:35:51 -03:00

90 lines
4.1 KiB
TypeScript

/**
* `</think>` close-marker client policy.
*
* When OmniRoute translates a Claude-native streamed response to OpenAI Chat
* Completions shape (`claude-to-openai.ts`), it historically emitted a single
* `</think>` close marker as `delta.content` so clients that scan content for
* the marker (Claude Code, Cursor) could split reasoning from the final answer
* — see #4633.
*
* Modern Chat Completions clients already receive reasoning on the separate
* `reasoning_content` field. Emitting the in-band marker by default now
* corrupts that split (literal `</think>` in visible content) for ordinary
* OpenAI clients — see #8245. OpenCode / Antigravity had the same leak earlier
* (#5245 / #1061).
*
* Policy (#8245):
* - Default: SUPPRESS the marker on OpenAI Chat Completions.
* - Explicit opt-in: `x-omniroute-thinking-marker: on` force-keeps it for the
* shrinking set of content-scanning clients (#4633).
* - Explicit opt-out: `x-omniroute-thinking-marker: off` (same as default).
* - Responses API (`openai-responses`): always suppress (#7747) — wins over
* the header; there is no legitimate marker consumer on that path.
*
* The User-Agent allowlist below is retained for diagnostics / callers that
* still query `shouldSuppressThinkCloseMarker` directly; the resolved default
* no longer depends on UA.
*/
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";
// Lowercased User-Agent substrings of clients that historically rendered the
// textual `</think>` marker verbatim (#5245 / #1061). Kept for direct callers;
// resolveSuppressThinkClose no longer needs UA to decide the default (#8245).
const SUPPRESS_THINK_CLOSE_UA_MARKERS = ["opencode", "antigravity"];
/**
* Whether the streamed `</think>` close marker should be suppressed for the
* given inbound client User-Agent. Prefer `resolveSuppressThinkClose` for
* request policy — UA alone is no longer the Chat Completions default (#8245).
*/
export function shouldSuppressThinkCloseMarker(userAgent: string | null | undefined): boolean {
if (!userAgent || typeof userAgent !== "string") return false;
const ua = userAgent.toLowerCase();
return SUPPRESS_THINK_CLOSE_UA_MARKERS.some((marker) => ua.includes(marker));
}
/**
* Interpret the explicit `x-omniroute-thinking-marker` request header.
* Returns `true` (suppress the marker), `false` (force-keep the marker), or
* `null` when the header is absent/unrecognized (defer to the default policy).
*/
export function thinkingMarkerHeaderSignal(
headerValue: string | null | undefined
): boolean | null {
if (typeof headerValue !== "string") return null;
const value = headerValue.trim().toLowerCase();
if (value === "off" || value === "false" || value === "0" || value === "suppress") return true;
if (value === "on" || value === "true" || value === "1" || value === "keep") return false;
return null;
}
/**
* Resolve whether the streamed `</think>` close marker should be suppressed for
* this request.
*
* Precedence:
* 1. Responses API format → always suppress (#7747)
* 2. Explicit `x-omniroute-thinking-marker` header → honor on/off (#5312)
* 3. Default → suppress on Chat Completions (#8245)
*/
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 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;
// #8245: suppress by default. Reasoning already ships as reasoning_content.
// Legacy content-scanning clients opt in with x-omniroute-thinking-marker: on.
return true;
}