mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
* fix(translator): Normalize tool call names from lowercase to PascalCase (#1) * Fix: Map lowercase tool names from Antigravity (Gemini format) to Claude Code expected PascalCase * Fix: toolNameMap in fun restoreClaudePassthroughToolUseName * fix(translator): Normalize tool call names from lowercase to PascalCase when translating upstream responses (OpenAI, Gemini, Antigravity) to Claude Messages API format This resolves `Error: No such tool available: read`/`bash`/`write` errors when using Claude Code CLI with third-party providers that emit lowercase tool names. The fix adds case-insensitive tool name lookups in `openai-to-claude.ts`, `gemini-to-claude.ts`, and related translators, ensuring tool names like `read`/`bash` are mapped to `Read`/`Bash` before being sent to Claude Code. Includes unit tests and comprehensive changelog notes ([#10250](https://github.com/diegosouzapw/OmniRoute/pull/10250)) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(translator): Parse <tool_call> JSON and TOOL_CALL text formats fr… (#2) * fix(translator): Parse <tool_call> JSON and TOOL_CALL text formats from model output Some models (DeepSeek, Qwen) emit tool calls as text instead of proper tool_calls JSON: either <tool_call>{...}</tool_call> or TOOL_CALL Name: {...}. Extend extractXmlInvokeBlocks to handle all 3 formats in a single scan pass, picking whichever pattern appears first. Includes unit tests for all formats. * fix(translator): Parse text-format tool calls in gemini-to-claude translator Extend the Gemini->Claude translator to detect <invoke>, <tool_call> JSON, and TOOL_CALL text formats emitted inline in text parts (Antigravity/Gemini models), converting them to proper tool_use content blocks instead of leaking raw text to Claude Code. * docs(changelog): Add changelog entry for text tool call parsing fix * fix(translator): consolidate tool name casing normalization and restore thought-signature persistence (#3) * fix(translator): sanitize tool_use.id and tool_result.tool_use_id to match Anthropic schema (#4) Ensure tool IDs from OpenAI-compatible upstreams (which may contain dots, colons, or special characters) are sanitized to ^[a-zA-Z0-9_-]+$ in response translators and passthrough requests before reaching Claude endpoints. * fix(responses): preserve native tools for openai-compatible Responses targets (#5) A Responses-shaped request to a custom openai-compatible connection whose outbound protocol is Responses took a Responses -> Chat -> Responses round trip, so Codex custom tools lost their grammar (`exec`), namespace groups were flattened (`collaboration`), and tool invocations failed upstream. Gate a native Responses passthrough on the connection's configured protocol (`apiType: "responses"` / `_omnirouteForceResponsesUpstream`) so the original tool definitions reach a Responses-capable upstream unchanged. Chat-only connections keep the existing downgrade. Closes #10374 * fix(translator): add support for 'applypatch' tool name in tool call checks * test(translator): add unit test for apply_patch and applypatch tool name remapping * fix(translator): remove no-explicit-any lint errors in tool-use-id-sanitization test Type the openaiToClaudeResponse/translateNonStreamingResponse return values with narrow local shapes instead of `any`, satisfying the repo's no-explicit-any = error rule for tests/. No behavior change — the same 3 assertions still pass. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test: update 9568 casing regression to match #10392's consolidated fix restoreClaudeToolName's static casing map now normalizes known lowercase tool names to canonical PascalCase unconditionally on the gemini-to-claude and openai-to-claude Claude Messages API paths (not gated behind toolNameMap), superseding the earlier per-map-only fix that the original #9568 regression test locked in as "expected" (it was previously labeled a known bug case). The gemini-to-openai passthrough path is unaffected by #10392 and keeps its original pass-through assertion. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
369 lines
9.9 KiB
TypeScript
369 lines
9.9 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,
|
|
GITHUB_COPILOT_CHAT_USER_AGENT,
|
|
} 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: GITHUB_COPILOT_CHAT_USER_AGENT,
|
|
},
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Apply a CLI fingerprint to headers and body.
|
|
* Returns { headers, bodyString } with the correct ordering.
|
|
*/
|
|
function stripInternalBodyFields(body: unknown): unknown {
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
|
|
const record = body as Record<string, unknown>;
|
|
delete record._claudeCodeRequiresLowercaseToolNames;
|
|
delete record._nativeCodexPassthrough;
|
|
delete record._nativeXaiResponsesPassthrough;
|
|
delete record._nativeOpenAICompatibleResponsesPassthrough;
|
|
delete record._omnirouteResponsesStore;
|
|
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;
|
|
}
|