Files
OmniRoute/open-sse/config/cliFingerprints.ts
Patryk Kopyciński f653a6f94e fix(dispatch): strip every _omniroute* internal marker before upstream send (#13355)
Merged. Internal `_omniroute*` markers must never reach an upstream: at best they are noise in someone else's logs, at worst they change the upstream's parse. Stripping every one of them before the send is the right invariant.

Validated as a combined board first (this PR merged with the 21 siblings of the same wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-counts, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 176 passing / 0 failing focused node:test cases across the 25 test files the wave touches and the dashboard test under Vitest (2/0). Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thank you.
2026-09-16 15:32:58 -03:00

397 lines
11 KiB
TypeScript

/**
* CLI Fingerprint Definitions
*
* Defines per-provider "fingerprints" that control the exact ordering of HTTP headers
* and JSON body fields to match the native CLI tools exactly.
*
* When `cliCompatMode` is enabled for a provider, OmniRoute reorders outgoing requests
* to be indistinguishable from the real CLI binary, reducing account flagging risk.
*
* Header order and body field order were captured via mitmproxy traffic analysis.
*/
import { isClaudeCodeCompatible } from "../services/provider.ts";
import {
getAntigravityUserAgent,
getGitHubCopilotChatUserAgent,
} from "./providerHeaderProfiles.ts";
import { normalizeCliCompatProviderId } from "@/shared/utils/cliCompat";
export interface CliFingerprint {
/** Ordered list of header names (case-sensitive). Unlisted headers are appended. */
headerOrder: string[];
/** Ordered list of top-level JSON body fields. Unlisted fields are appended. */
bodyFieldOrder: string[];
/** User-Agent string to inject (overrides default) */
userAgent?: string | (() => string);
/** Extra headers to add */
extraHeaders?: Record<string, string>;
}
/**
* Fingerprint registry - keyed by provider alias (lowercase).
* Based on mitmproxy traffic captures from native CLI tools.
*/
export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
codex: {
headerOrder: [
"Host",
"Content-Type",
"Authorization",
"Accept",
"User-Agent",
"Accept-Encoding",
],
bodyFieldOrder: [
"model",
"stream",
"input",
"instructions",
"store",
"reasoning",
"prompt_cache_key",
"tools",
"tool_choice",
"include",
"service_tier",
"client_metadata",
"parallel_tool_calls",
"metadata",
],
// Codex builds mode-specific client headers in its executor/config. The CLI fingerprint must
// only preserve ordering here; overriding User-Agent with a generic value would erase the
// executor-provided version or user override.
},
claude: {
// Header order matching real claude-cli: Title-Case (Stainless) keys
// alphabetically, then lowercase Anthropic keys alphabetically, then
// transport headers added by Node fetch.
headerOrder: [
"Accept",
"Authorization",
"Content-Type",
"User-Agent",
"X-Claude-Code-Session-Id",
"X-Stainless-Arch",
"X-Stainless-Lang",
"X-Stainless-OS",
"X-Stainless-Package-Version",
"X-Stainless-Retry-Count",
"X-Stainless-Runtime",
"X-Stainless-Runtime-Version",
"X-Stainless-Timeout",
"anthropic-beta",
"anthropic-dangerous-direct-browser-access",
"anthropic-version",
"x-app",
"x-client-request-id",
"Connection",
"Host",
"Accept-Encoding",
"Content-Length",
],
bodyFieldOrder: [
"model",
"messages",
"system",
"tools",
"tool_choice",
"metadata",
"max_tokens",
"temperature",
"thinking",
"context_management",
"output_config",
"stream",
],
},
"claude-code-compatible": {
headerOrder: [
"Host",
"Content-Type",
"Authorization",
"anthropic-version",
"anthropic-beta",
"anthropic-dangerous-direct-browser-access",
"x-app",
"User-Agent",
"X-Claude-Code-Session-Id",
"X-Stainless-Retry-Count",
"X-Stainless-Timeout",
"X-Stainless-Lang",
"X-Stainless-Package-Version",
"X-Stainless-OS",
"X-Stainless-Arch",
"X-Stainless-Runtime",
"X-Stainless-Runtime-Version",
"Accept",
"accept-encoding",
"Connection",
],
bodyFieldOrder: [
"model",
"messages",
"system",
"tools",
"tool_choice",
"metadata",
"max_tokens",
"thinking",
"output_config",
"stream",
],
},
github: {
headerOrder: [
"Host",
"Authorization",
"X-Request-Id",
"Vscode-Sessionid",
"Vscode-Machineid",
"Editor-Version",
"Editor-Plugin-Version",
"Copilot-Integration-Id",
"Openai-Organization",
"Openai-Intent",
"Content-Type",
"User-Agent",
"Accept",
"Accept-Encoding",
],
bodyFieldOrder: [
"messages",
"model",
"temperature",
"top_p",
"max_tokens",
"n",
"stream",
"intent",
"intent_threshold",
"intent_content",
],
userAgent: getGitHubCopilotChatUserAgent,
},
antigravity: {
headerOrder: [
"Accept",
"Accept-Encoding",
"Authorization",
"Content-Type",
"User-Agent",
"x-goog-api-client",
"x-client-name",
"x-client-version",
"x-machine-id",
"x-vscode-sessionid",
"Host",
"Connection",
],
bodyFieldOrder: [
"project",
"requestId",
"request",
"model",
"userAgent",
"requestType",
"enabledCreditTypes",
],
userAgent: getAntigravityUserAgent,
},
};
/**
* Reorder an object's keys according to a specified order.
* Keys not in the order list are appended at the end in their original order.
*/
export function orderFields<T extends Record<string, unknown>>(obj: T, fieldOrder: string[]): T {
if (!fieldOrder?.length || !obj || typeof obj !== "object") return obj;
const result: Record<string, unknown> = {};
const remaining = new Set(Object.keys(obj));
// First, add fields in the specified order
for (const key of fieldOrder) {
if (key in obj) {
result[key] = obj[key];
remaining.delete(key);
}
}
// Then append remaining fields in original order
for (const key of remaining) {
result[key] = obj[key];
}
return result as T;
}
/**
* Reorder HTTP headers according to a fingerprint.
* Returns a new object with headers in the specified order.
*/
export function orderHeaders(
headers: Record<string, string>,
headerOrder: string[]
): Record<string, string> {
if (!headerOrder?.length || !headers) return headers;
const result: Record<string, string> = {};
const remaining = new Map<string, string>();
// Build case-insensitive lookup
const headerMap = new Map<string, [string, string]>();
for (const [key, value] of Object.entries(headers)) {
headerMap.set(key.toLowerCase(), [key, value]);
}
// Add ordered headers first
for (const orderedKey of headerOrder) {
const entry = headerMap.get(orderedKey.toLowerCase());
if (entry) {
result[entry[0]] = entry[1];
headerMap.delete(orderedKey.toLowerCase());
}
}
// Add remaining headers
for (const [, [key, value]] of headerMap) {
result[key] = value;
}
return result;
}
/**
* Internal request-body markers that are NOT `_omniroute*`-prefixed and must be
* removed key-by-key. Everything else is caught by INTERNAL_BODY_FIELD_PREFIX.
*/
const INTERNAL_BODY_FIELDS: readonly string[] = [
"_claudeCodeRequiresLowercaseToolNames",
"_nativeCodexPassthrough",
"_nativeXaiResponsesPassthrough",
"_nativeOpenAICompatibleResponsesPassthrough",
];
/**
* Every omniroute-owned internal marker uses this prefix, so the strip is
* prefix-based rather than an allowlist. An allowlist silently leaks each newly
* added marker to the upstream, where strict gateways reject the whole request
* (observed live: `[400]: _omnirouteSkipContextRelay: Extra inputs are not
* permitted` on a claude hop, from the context/universal-handoff markers set in
* `open-sse/services/contextHandoff.ts`). Markers are consumed by routing before
* dispatch, so removing them at this chokepoint is always safe.
*
* Deliberately narrow: only omniroute-owned prefixes are ours. A caller-sent
* field that merely starts with `_` is client payload and passes through.
*/
const INTERNAL_BODY_FIELD_PREFIX = "_omniroute";
/**
* Remove omniroute-internal markers from a request body before it is serialized
* for an upstream. Mutates and returns the same object.
*/
export function stripInternalBodyFields(body: unknown): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const record = body as Record<string, unknown>;
for (const field of INTERNAL_BODY_FIELDS) {
delete record[field];
}
for (const key of Object.keys(record)) {
if (key.startsWith(INTERNAL_BODY_FIELD_PREFIX)) {
delete record[key];
}
}
return body;
}
export function applyFingerprint(
provider: string,
headers: Record<string, string>,
body: unknown
): { headers: Record<string, string>; bodyString: string } {
body = stripInternalBodyFields(body);
const normalizedProvider = normalizeCliCompatProviderId(provider || "");
const fingerprintKey = isClaudeCodeCompatible(provider)
? "claude-code-compatible"
: normalizedProvider;
const fingerprint = CLI_FINGERPRINTS[fingerprintKey];
if (!fingerprint) {
return { headers, bodyString: JSON.stringify(body) };
}
// Apply user agent override
if (fingerprint.userAgent) {
headers["User-Agent"] =
typeof fingerprint.userAgent === "function" ? fingerprint.userAgent() : fingerprint.userAgent;
}
// Apply extra headers
if (fingerprint.extraHeaders) {
Object.assign(headers, fingerprint.extraHeaders);
}
// Reorder headers
const orderedHeaders = orderHeaders(headers, fingerprint.headerOrder);
// Reorder body fields
const orderedBody =
body && typeof body === "object" && !Array.isArray(body)
? orderFields(body as Record<string, unknown>, fingerprint.bodyFieldOrder)
: body;
return {
headers: orderedHeaders,
bodyString: JSON.stringify(orderedBody),
};
}
/**
* Runtime cache for CLI compat providers set via Settings UI.
* Updated by the settings API when users toggle providers.
*/
let _cliCompatProviders: Set<string> = new Set();
/**
* Update the runtime cache of CLI-compat-enabled providers.
* Called from the settings API when cliCompatProviders is updated.
*/
export function setCliCompatProviders(providers: string[]): void {
_cliCompatProviders = new Set(
(providers || [])
.map((p) => normalizeCliCompatProviderId(p))
.filter((provider) => provider in CLI_FINGERPRINTS)
);
}
/**
* Get the current list of CLI-compat-enabled providers.
*/
export function getCliCompatProviders(): string[] {
return Array.from(_cliCompatProviders);
}
/**
* Check if CLI compatibility mode is enabled for a provider.
* Reads from: 1) Runtime cache (Settings UI), 2) Environment variables.
*/
export function isCliCompatEnabled(provider: string): boolean {
if (isClaudeCodeCompatible(provider)) return true;
const key = provider?.toLowerCase().replace(/[^a-z0-9]/g, "_");
// 1. Check runtime cache (set via Settings UI)
const normalizedProvider = normalizeCliCompatProviderId(provider || "");
if (_cliCompatProviders.has(normalizedProvider)) return true;
// 2. Check environment variable: CLI_COMPAT_<PROVIDER>=1
const envKey = `CLI_COMPAT_${key?.toUpperCase()}`;
if (process.env[envKey] === "1" || process.env[envKey] === "true") return true;
// 3. Global enable: CLI_COMPAT_ALL=1
if (process.env.CLI_COMPAT_ALL === "1" || process.env.CLI_COMPAT_ALL === "true") return true;
return false;
}