Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,11 @@ CURSOR_USER_AGENT="Cursor/3.4"
# Override the advertised GitHub Copilot CLI version independently of
# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts.
# GITHUB_COPILOT_CLI_VERSION=1.0.82
#
# Override the advertised Grok Build (grok-cli) client version independently
# of the full User-Agent string. xAI enforces minimum client version gates
# (HTTP 426). Used by: open-sse/config/grokBuild.ts.
# GROK_CLI_CLIENT_VERSION=1.0.44

# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
# from the Codex Responses stream. These frames break the OpenAI SDK's
Expand Down Expand Up @@ -1601,6 +1606,7 @@ CURSOR_USER_AGENT="Cursor/3.4"

# ── TLS client (wreq-js fingerprint proxy) ──
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
# TLS_FIRST_BYTE_WATCHDOG_MS=10000 # #12656: bounds time-to-first-byte on the wreq body (0 disables)

# ── API Bridge (/v1 proxy server) ──
# API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min)
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixes/v1beta-gemini-format-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(api):** `/v1beta` Gemini requests with `generationConfig.maxOutputTokens` now return properly shaped Gemini replies ([#14165](https://github.com/diegosouzapw/OmniRoute/issues/14165)). The ingress converts the body to OpenAI chat format before re-entering `handleChat` while the URL keeps its `/v1beta` path, and with no path branch `detectFormat`'s `max_tokens` heuristic misread the converted body as `claude` — non-streaming callers got an anthropic-shaped body the route's OpenAI→Gemini converter silently dropped, and streaming callers got a 200 SSE response with zero bytes, looping the antigravity CLI and the Google GenAI SDK. `detectFormatFromEndpoint` now treats a `/v1beta` path as OpenAI chat unless the body still carries the raw Gemini `contents` envelope. Raw Gemini bodies (client-raw-request contexts) are unaffected.
2 changes: 2 additions & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`]
| `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string |
| `CLAUDE_CODE_CLIENT_VERSION` | `2.1.258` | Override advertised Claude Code version independently of `CLAUDE_USER_AGENT`. Anthropic gates some models on this value (#12417). |
| `GITHUB_COPILOT_CLI_VERSION` | `1.0.81-6` | Override advertised Copilot CLI version independently of `GITHUB_USER_AGENT` |
| `GROK_CLI_CLIENT_VERSION` | `1.0.44` | Override advertised Grok Build (grok-cli) client version independently of the full User-Agent string. xAI enforces minimum client version gates (HTTP 426). Values not matching `^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$` are ignored (`open-sse/config/grokBuild.ts`). |
| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | When GitHub Copilot Chat updates |
| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 darwin/arm64` | When Antigravity IDE updates |
| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` | When Kiro IDE updates |
Expand Down Expand Up @@ -749,6 +750,7 @@ REQUEST_TIMEOUT_MS (global override)
| `FETCH_CONNECT_TIMEOUT_MS` | `30000` | TCP connection establishment timeout. |
| `FETCH_KEEPALIVE_TIMEOUT_MS` | `4000` | Keep-alive socket idle timeout. |
| `TLS_CLIENT_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | TLS fingerprint proxy (wreq-js) timeout. |
| `TLS_FIRST_BYTE_WATCHDOG_MS` | `10000` | Time-to-first-byte bound (ms) on the wreq-js TLS fingerprint transport body, so a wedged body fails fast instead of riding `TLS_CLIENT_TIMEOUT_MS` (#12656). Set `0` to disable; invalid values fall back to the default. |
| `API_BRIDGE_PROXY_TIMEOUT_MS` | `30000` | Proxy hop timeout for `/v1` bridge requests. |
| `FIRECRAWL_BASE_URL` | `https://api.firecrawl.dev` | Point the Firecrawl web-fetch executor at a self-hosted instance (API key optional off-cloud). |
| `FIRECRAWL_TIMEOUT_MS` | `30000` | Per-request timeout for the Firecrawl web-fetch executor. |
Expand Down
3 changes: 3 additions & 0 deletions open-sse/config/codexClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DEFAULT_CODEX_CLIENT_VERSION,
getCodexCliRsHeaders as buildCodexCliRsHeaders,
} from "@/shared/constants/codexClient";
import { getActiveClientVersion } from "@/lib/client-versions/registry";

export {
DEFAULT_CODEX_CLIENT_VERSION,
Expand All @@ -27,6 +28,8 @@ function getSafeEnvValue(name: string, pattern: RegExp): string | null {
}

export function getCodexClientVersion(): string {
const dynamic = getActiveClientVersion("codex");
if (dynamic) return dynamic;
return (
getSafeEnvValue(CODEX_VERSION_OVERRIDE_ENV, SAFE_HEADER_TOKEN_PATTERN) ||
DEFAULT_CODEX_CLIENT_VERSION
Expand Down
34 changes: 34 additions & 0 deletions open-sse/config/codexIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,3 +570,37 @@ export function isVerifiedNativeCodexRequest(
): boolean {
return isCodexOriginatedHeaders(headers) && hasNativeCodexTurnBinding(body);
}

/**
* Detect the Claude Code CLI as the request *client* from request headers.
* Used to auto-enable model echo so session restores work when the resolved
* upstream model (e.g. `oc/nemotron-3-ultra-free`) is not recognized by the
* Claude Code client on `--resume`.
*/
export function isClaudeCodeOriginatedHeaders(
headers: Headers | Record<string, unknown> | null | undefined
): boolean {
const getHeader = (name: string): string => {
if (headers instanceof Headers) {
return headers.get(name)?.toLowerCase() ?? "";
}
if (headers && typeof headers === "object") {
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
if (key.toLowerCase() === name && typeof value === "string") {
return value.toLowerCase();
}
}
}
return "";
};

// Claude Code identifies itself via the user-agent header
const userAgent = getHeader("user-agent");
if (userAgent.includes("claude-code") || userAgent.includes("anthropic-ai/claude-code")) {
return true;
}
// Also check originator if present
const originator = getHeader("originator");
if (originator.startsWith("claude-code")) return true;
return false;
}
19 changes: 18 additions & 1 deletion open-sse/config/grokBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const GROK_BUILD_OAUTH_ISSUER = "https://auth.x.ai";
export const GROK_BUILD_DEVICE_CODE_URL = `${GROK_BUILD_OAUTH_ISSUER}/oauth2/device/code`;
export const GROK_BUILD_TOKEN_URL = `${GROK_BUILD_OAUTH_ISSUER}/oauth2/token`;

export const GROK_BUILD_DEFAULT_CLIENT_VERSION = "0.2.106";
export const GROK_BUILD_DEFAULT_CLIENT_VERSION = "1.0.44";
export const GROK_BUILD_DEFAULT_CONTEXT_WINDOW = 256_000;
export const GROK_BUILD_DEFAULT_REASONING_EFFORT = "high";
export const GROK_BUILD_SUPPORTED_REASONING_EFFORTS = Object.freeze(["low", "medium", "high"]);
Expand All @@ -17,6 +17,9 @@ export const GROK_BUILD_TOKEN_AUTH = "xai-grok-cli";
export const GROK_BUILD_REASONING_INCLUDE = "reasoning.encrypted_content";
export const GROK_BUILD_OAUTH_REFERRER = "grok-build";

const GROK_CLI_VERSION_OVERRIDE_ENV = "GROK_CLI_CLIENT_VERSION";
const SAFE_HEADER_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;

export const GROK_BUILD_OAUTH_SCOPES = Object.freeze([
"openid",
"profile",
Expand Down Expand Up @@ -62,7 +65,21 @@ function mapArch(arch: string): string {
return arch;
}

function getSafeEnvValue(name: string, pattern: RegExp): string | null {
const raw = process.env[name];
if (typeof raw !== "string") return null;
const normalized = raw.trim();
if (!normalized || !pattern.test(normalized)) {
return null;
}
return normalized;
}

export function getGrokBuildClientVersion(): string {
const envOverride = getSafeEnvValue(GROK_CLI_VERSION_OVERRIDE_ENV, SAFE_HEADER_TOKEN_PATTERN);
if (envOverride) {
return envOverride;
}
return GROK_BUILD_DEFAULT_CLIENT_VERSION;
}

Expand Down
25 changes: 1 addition & 24 deletions open-sse/executors/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
} from "./codex/reasoningSuffix.ts";
import { repairMissingCodexToolCallOutputs } from "./codex/toolCallRepair.ts";
import { resolveAppServerConfig } from "./codex/appServerConfig.ts";
import { getResponsesSubpath } from "./codex/responsesSubpath.ts";
import { CodexAppServerExecutor } from "./codex-app-server.ts";
// Re-exported for external importers (tests + provider services).
export { isCodexFreePlan, normalizeCodexTools } from "./codex/tools.ts";
Expand Down Expand Up @@ -287,30 +288,6 @@ function stripOrphanedCodexFunctionCallOutputs(body: Record<string, unknown>): v
}
}

function getResponsesSubpath(endpointPath: unknown): string | null {
let normalizedEndpoint = String(endpointPath || "");
while (normalizedEndpoint.endsWith("/") && normalizedEndpoint.length > 0) {
normalizedEndpoint = normalizedEndpoint.slice(0, -1);
}

const lower = normalizedEndpoint.toLowerCase();
if (lower === "responses" || lower.endsWith("/responses")) {
return "";
}

const responsesSlash = "/responses/";
const idx = lower.lastIndexOf(responsesSlash);
if (idx !== -1) {
return normalizedEndpoint.slice(idx + "/responses".length);
}

if (lower.startsWith("responses/")) {
return normalizedEndpoint.slice("responses".length);
}

return null;
}

export function isCompactResponsesEndpoint(endpointPath: unknown): boolean {
return getResponsesSubpath(endpointPath)?.toLowerCase() === "/compact";
}
Expand Down
45 changes: 45 additions & 0 deletions open-sse/executors/codex/responsesSubpath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Extracts the `/v1/responses/<subpath>` suffix the Codex executor forwards upstream
* (e.g. `/compact`, `/<id>/cancel`). Returns "" for the plain endpoint and null when the
* path is not a Responses path or the subpath is unsafe to append.
*/

// The subpath comes from the request URL and is appended to the upstream URL verbatim, so
// anything the upstream (or fetch's URL parser) would read as path traversal or as the end of
// the path is refused. The caller then falls back to the plain /responses endpoint.
const UNSAFE_SUBPATH_ESCAPE = /%(?:2e|2f|5c|23|3f|00)/i;

function isSafeResponsesSubpath(subpath: string): boolean {
if (subpath === "") return true;
if (/[\\?#\u0000]/.test(subpath) || UNSAFE_SUBPATH_ESCAPE.test(subpath)) return false;
return !subpath.split("/").some((segment) => segment === "." || segment === "..");
}

export function getResponsesSubpath(endpointPath: unknown): string | null {
const subpath = findResponsesSubpath(endpointPath);
return subpath !== null && isSafeResponsesSubpath(subpath) ? subpath : null;
}

function findResponsesSubpath(endpointPath: unknown): string | null {
let normalizedEndpoint = String(endpointPath || "");
while (normalizedEndpoint.endsWith("/") && normalizedEndpoint.length > 0) {
normalizedEndpoint = normalizedEndpoint.slice(0, -1);
}

const lower = normalizedEndpoint.toLowerCase();
if (lower === "responses" || lower.endsWith("/responses")) {
return "";
}

const responsesSlash = "/responses/";
const idx = lower.lastIndexOf(responsesSlash);
if (idx !== -1) {
return normalizedEndpoint.slice(idx + "/responses".length);
}

if (lower.startsWith("responses/")) {
return normalizedEndpoint.slice("responses".length);
}

return null;
}
2 changes: 2 additions & 0 deletions open-sse/executors/geminiCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
reassembleGeminiCliChunks,
type GeminiCliResponseAccumulator,
} from "../translator/response/geminiCli.ts";
import { getActiveClientVersion } from "@/lib/client-versions/registry";

export const GEMINI_CLI_ENDPOINT_FALLBACKS = [
"https://cloudcode-pa.googleapis.com/v1internal",
Expand Down Expand Up @@ -75,6 +76,7 @@ export function buildGeminiCliHeaders(
): Record<string, string> {
const uaVer =
env.uaVersion ||
getActiveClientVersion("gemini-cli") ||
process.env.GEMINI_CLI_UA_VERSION ||
process.env.GEMINI_CLI_CLIENT_VERSION ||
GEMINI_CLI_UA_VERSION;
Expand Down
33 changes: 25 additions & 8 deletions open-sse/executors/grok-web/tool-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// OpenAI <-> Grok tool-call translation (pure). Extracted verbatim from grok-web.ts.
import type { GrokStreamResponse } from "./types.ts";
import { findTagBlocks } from "../../utils/tagBlocks.ts";

// ─── OpenAI message → Grok query translation ───────────────────────────────

Expand Down Expand Up @@ -32,12 +33,29 @@ export interface ToolBridgeContext {
lastUserText: string;
}

/**
* Where a reminder block starts once the `---` separator line the client put in front of it is
* counted: `\n?---`, blanks, a newline and any further whitespace, right before the tag.
*/
function reminderBlockStart(text: string, floor: number, tagStart: number): number {
let runStart = tagStart;
while (runStart > floor && /\s/.test(text[runStart - 1])) runStart -= 1;
if (!text.slice(runStart, tagStart).includes("\n")) return tagStart;
if (runStart - 3 < floor || text.slice(runStart - 3, runStart) !== "---") return tagStart;
const separatorStart = runStart - 3;
return separatorStart > floor && text[separatorStart - 1] === "\n"
? separatorStart - 1
: separatorStart;
}

export function stripInjectedRuntimeReminders(text: string): string {
return text
.replace(/\n?---\s*\n\s*<internal_reminder>[\s\S]*?<\/internal_reminder>/gi, "")
.replace(/<internal_reminder>[\s\S]*?<\/internal_reminder>/gi, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
let kept = "";
let position = 0;
for (const block of findTagBlocks(text, /<internal_reminder>/gi, /<\/internal_reminder>/gi)) {
kept += text.slice(position, reminderBlockStart(text, position, block.start));
position = block.end;
}
return (kept + text.slice(position)).replace(/\n{3,}/g, "\n\n").trim();
}

export function extractTextContent(msg: Record<string, unknown>): string {
Expand Down Expand Up @@ -629,11 +647,10 @@ export function parseClientToolCallMarkup(
): OpenAIToolCall[] | null {
if (!toolRegistry.enabled || !text.includes("<tool_call>")) return null;
const calls: OpenAIToolCall[] = [];
const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
for (const match of text.matchAll(re)) {
for (const block of findTagBlocks(text, /<tool_call>/g, /<\/tool_call>/g)) {
let parsed: unknown;
try {
parsed = JSON.parse(match[1]);
parsed = JSON.parse(block.inner.trim());
} catch {
continue;
}
Expand Down
3 changes: 2 additions & 1 deletion open-sse/executors/trae.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { resolvePublicCred } from "../utils/publicCreds.ts";
import { resolveTraeApiHost } from "../utils/traeHost.ts";

type JsonRecord = Record<string, unknown>;
type ChatMessage = { role?: string; content?: unknown };
Expand Down Expand Up @@ -438,7 +439,7 @@ export class TraeExecutor extends BaseExecutor {
const psd = (credentials?.providerSpecificData as JsonRecord) || {};
const refreshToken = credentials?.refreshToken as string | undefined;
if (!refreshToken) return null;
const host = ((psd.host as string) || "https://api-us-east.trae.ai").replace(/\/$/, "");
const host = resolveTraeApiHost(psd.host);
const clientId =
(psd.clientId as string) || resolvePublicCred("trae_id", "TRAE_OAUTH_CLIENT_ID");
const url = `${host}/cloudide/api/v3/trae/oauth/ExchangeToken`;
Expand Down
Loading
Loading