mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* 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>
92 lines
4.5 KiB
TypeScript
92 lines
4.5 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 emits a single `</think>`
|
|
* close marker as `delta.content` so clients that scan content for the marker
|
|
* (Claude Code, Cursor) can split reasoning from the final answer — see #4633.
|
|
*
|
|
* Some OpenAI-compatible consumers do NOT parse that marker and render it
|
|
* verbatim, so a bare `</think>` leaks into the visible reply (#5245). OpenCode
|
|
* is one such client.
|
|
*
|
|
* Policy is conservative and opt-OUT by allowlist: the marker stays ON by
|
|
* default (preserving #4633 for Claude Code / Cursor and any unrecognized
|
|
* client), and is suppressed ONLY for known clients that render it literally.
|
|
* Detection is by inbound `User-Agent`.
|
|
*
|
|
* Clients that DO render the marker verbatim but are not in the UA allowlist
|
|
* (e.g. Cursor's reasoning_content-native OpenAI path — #5312 / #5245) can opt
|
|
* in explicitly with the request header `x-omniroute-thinking-marker: off`,
|
|
* 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";
|
|
|
|
// Lowercased User-Agent substrings of clients that render the textual
|
|
// `</think>` marker verbatim and therefore want it suppressed.
|
|
// - `opencode` (#5245): renders the marker as literal text.
|
|
// - `antigravity` (#1061): the Antigravity IDE client (UA
|
|
// `vscode/<v> (Antigravity/<v>)`) renders a bare `</think>` as the sole
|
|
// visible content on thinking-only turns, which trips its loop-detection.
|
|
const SUPPRESS_THINK_CLOSE_UA_MARKERS = ["opencode", "antigravity"];
|
|
|
|
/**
|
|
* Whether the streamed `</think>` close marker should be suppressed for the
|
|
* given inbound client. Returns false (emit the marker) for unknown clients and
|
|
* for Claude Code / Cursor, so #4633 is never regressed.
|
|
*/
|
|
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 UA 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. An explicit `x-omniroute-thinking-marker` header wins; absent
|
|
* that, the conservative User-Agent allowlist policy applies. With no header and
|
|
* an unrecognized UA the result is `false` (marker kept), so #4633 / #5123 stay
|
|
* byte-identical by default.
|
|
*/
|
|
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);
|
|
}
|