Compare commits
16 Commits
fix/sec-ad
...
fix/11236-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b9698467 | ||
|
|
a7e09eda5c | ||
|
|
24d7308010 | ||
|
|
855243ab18 | ||
|
|
8f390efffd | ||
|
|
968fa96105 | ||
|
|
c8369193c1 | ||
|
|
90ba281e7f | ||
|
|
84bd382d94 | ||
|
|
8d59ab363b | ||
|
|
fb421bc580 | ||
|
|
3d8448b45e | ||
|
|
e66181b182 | ||
|
|
7af14d2696 | ||
|
|
1135cbea80 | ||
|
|
6e9553eb93 |
24
.env.example
@@ -1303,6 +1303,30 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# set to true/1/yes to enable. Used by: open-sse/executors/codex.ts.
|
||||
# OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS=true
|
||||
|
||||
# Codex app-server WebSocket transport (opt-in). When a WebSocket URL and a
|
||||
# capability token are both provided, Codex requests are routed through a local
|
||||
# `codex app-server` sidecar over JSON-RPC instead of the HTTP Responses API.
|
||||
# Each var is also settable per-connection via providerSpecificData; the env var
|
||||
# is the process-wide fallback. Used by:
|
||||
# open-sse/executors/codex/appServerConfig.ts.
|
||||
#
|
||||
# WebSocket endpoint of the codex app-server (ws:// or wss://). Required to
|
||||
# enable the transport; leaving it unset keeps Codex on its HTTP transports.
|
||||
# OMNIROUTE_CODEX_APPSERVER_WS=ws://127.0.0.1:8081
|
||||
# Inline capability/bearer token presented to the app-server.
|
||||
# OMNIROUTE_CODEX_APPSERVER_WS_TOKEN=deadbeef...
|
||||
# Path to a file holding the capability token (produced by
|
||||
# `codex app-server --ws-token-file <path>`). Used when the inline token above
|
||||
# is not set.
|
||||
# OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE=/run/codex-ws-token
|
||||
# Working directory the app-server turn runs in (defaults to /tmp).
|
||||
# OMNIROUTE_CODEX_APPSERVER_CWD=/tmp
|
||||
# Approval policy passed to the app-server turn (e.g. never, on-request).
|
||||
# OMNIROUTE_CODEX_APPSERVER_APPROVAL=never
|
||||
# Sandbox policy passed to the app-server turn (e.g. read-only,
|
||||
# workspace-write, danger-full-access).
|
||||
# OMNIROUTE_CODEX_APPSERVER_SANDBOX=read-only
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -92,5 +92,9 @@
|
||||
# - x-api-key PUBLICO do Firefly web (documentado em open-sse/utils/publicCreds.ts:207);
|
||||
# as duas ocorrencias sinalizadas estao em COMENTARIOS JSDoc, o runtime le de resolvePublicCred().
|
||||
'''omniroute-kimi-sponsor-banner-dismissed-v\d+''',
|
||||
# CheaperInference sponsor banner localStorage key (upstream #11196 /
|
||||
# eb5797370). Same UI-identifier pattern as the kimi banner above, not a
|
||||
# credential; the generic-api-key rule flags the long hyphenated string.
|
||||
'''omniroute-cheaperinference-sponsor-banner-dismissed-v\d+''',
|
||||
'''SunbreakWebUI1''',
|
||||
]
|
||||
|
||||
@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 350 LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 351 LLM providers, auto-fallback.
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
10
README.md
@@ -7,7 +7,7 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 349 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 350 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 351 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 351 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 349 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 349 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 351 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 351 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step:
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 349 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs."/>
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 351 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
@@ -612,7 +612,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
<b>+ also works with</b> · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
|
||||
</div>
|
||||
|
||||
<sub>📖 Per-tool setup for all 34 tools (26 CLI Code's + 8 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
|
||||
<sub>📖 Per-tool setup for all 35 tools (26 CLI Code's + 9 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -646,7 +646,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
|
||||
</div>
|
||||
|
||||
> The most complete catalog of any open-source router: **349 providers**, **90+ with a free tier**, **56 free forever**.
|
||||
> The most complete catalog of any open-source router: **351 providers**, **90+ with a free tier**, **56 free forever**.
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
@@ -258,8 +258,7 @@ async function runDeviceFlow(def, opts) {
|
||||
process.stdout.write(`\nAuthorization URL not available\n\n`);
|
||||
}
|
||||
|
||||
if (opts.browser !== false && verificationUri)
|
||||
await openBrowser(verificationUri);
|
||||
if (opts.browser !== false && verificationUri) await openBrowser(verificationUri);
|
||||
process.stderr.write("Waiting for device authorization...\n");
|
||||
const deadline = Date.now() + (opts.timeout ?? 300000);
|
||||
const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
|
||||
@@ -320,7 +319,18 @@ export async function runOAuthStatus(opts, cmd) {
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
const connections = (data.connections ?? data.providers ?? data.items ?? data).filter(
|
||||
const payload = data?.connections ?? data?.providers ?? data?.items ?? data;
|
||||
// #11236 (bug 5 residual): a 200 whose body is out of contract (no
|
||||
// connections/providers/items array — e.g. `{"status":"ok"}`) used to fall
|
||||
// through to `.filter` on a non-array and crash with a bare TypeError plus a
|
||||
// libuv teardown assertion on Windows. Coerce to an empty list with a
|
||||
// sanitized one-line warning instead of dumping a stack trace.
|
||||
if (!Array.isArray(payload)) {
|
||||
process.stderr.write(
|
||||
"Warning: unexpected response shape from /api/providers; showing no connections.\n"
|
||||
);
|
||||
}
|
||||
const connections = (Array.isArray(payload) ? payload : []).filter(
|
||||
(c) => c.authType === "oauth" || c.authType === "oauth2"
|
||||
);
|
||||
emit(connections, globalOpts, connectionSchema);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
buildNodeHeapArgs,
|
||||
} from "../../../scripts/build/runtime-env.mjs";
|
||||
import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs";
|
||||
import { startDetachedTray, validateTrayOptions } from "../tray/detachedTray.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "package.json"), "utf8"));
|
||||
@@ -42,7 +43,7 @@ function parsePort(value, fallback) {
|
||||
}
|
||||
|
||||
export function registerServe(program) {
|
||||
program
|
||||
const command = program
|
||||
.command("serve", { isDefault: true })
|
||||
.description(t("serve.description"))
|
||||
.option("--port <port>", t("serve.port"))
|
||||
@@ -51,7 +52,7 @@ export function registerServe(program) {
|
||||
.option("--log", t("serve.log"))
|
||||
.option("--no-recovery", t("serve.no_recovery"))
|
||||
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
|
||||
.option("--tray", t("serve.tray") || "Show system tray icon (desktop only)")
|
||||
.option("--tray", t("serve.tray") || "Start in the system tray (desktop only)")
|
||||
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon")
|
||||
.option(
|
||||
"--tls-cert <path>",
|
||||
@@ -66,6 +67,9 @@ export function registerServe(program) {
|
||||
.action(async (opts) => {
|
||||
await runServe(opts);
|
||||
});
|
||||
command.addOption(command.createOption("--tray-worker").hideHelp());
|
||||
command.addOption(command.createOption("--tray-ready-port <port>").hideHelp());
|
||||
command.addOption(command.createOption("--tray-ready-token <token>").hideHelp());
|
||||
}
|
||||
|
||||
/** Once-per-process guard so the Android/Termux cache hint is not spammed. */
|
||||
@@ -95,6 +99,32 @@ export function resetInstrumentationFailureHintForTests() {
|
||||
export async function runServe(opts = {}) {
|
||||
const startedAt = performance.now();
|
||||
|
||||
const trayOptionError = validateTrayOptions(opts);
|
||||
if (trayOptionError) throw new Error(trayOptionError);
|
||||
|
||||
if (opts.tray === true && opts.trayWorker !== true) {
|
||||
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128);
|
||||
const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT;
|
||||
const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY;
|
||||
urlScheme = resolveTlsOptions({
|
||||
...process.env,
|
||||
...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}),
|
||||
...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}),
|
||||
})
|
||||
? "https"
|
||||
: "http";
|
||||
const result = await startDetachedTray({
|
||||
cliPath: join(ROOT, "bin", "omniroute.mjs"),
|
||||
port,
|
||||
maxRestarts: opts.maxRestarts ?? 2,
|
||||
tlsCert,
|
||||
tlsKey,
|
||||
});
|
||||
console.log(`\x1b[32m✔ OmniRoute tray started in background\x1b[0m`);
|
||||
console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${port}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Same prep as bin/omniroute.mjs — keep it here so a direct `runServe()` call
|
||||
// (tests / programmatic) still gets a writable Next.js cache dir before spawn.
|
||||
ensureAndroidCacheDir({ env: process.env });
|
||||
@@ -255,7 +285,8 @@ export async function runServe(opts = {}) {
|
||||
opts.log === true,
|
||||
opts.maxRestarts ?? 2,
|
||||
startedAt,
|
||||
useTray
|
||||
useTray,
|
||||
{ trayReadyPort: opts.trayReadyPort, trayReadyToken: opts.trayReadyToken }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,9 +399,11 @@ async function runWithSupervisor(
|
||||
showLog,
|
||||
maxRestarts,
|
||||
startedAt,
|
||||
useTray = false
|
||||
useTray = false,
|
||||
{ trayReadyPort, trayReadyToken } = {}
|
||||
) {
|
||||
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
|
||||
writePidFile("supervisor", process.pid);
|
||||
|
||||
const supervisor = new ServerSupervisor({
|
||||
serverPath: serverJs,
|
||||
@@ -394,17 +427,38 @@ async function runWithSupervisor(
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
});
|
||||
|
||||
if (!showLog) {
|
||||
waitForServer(dashboardPort, 60000).then(async (up) => {
|
||||
if (up) {
|
||||
if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
if (useTray) {
|
||||
const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
if (!trayReady) {
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (trayReadyPort && trayReadyToken) {
|
||||
const { notifyTrayReady } = await import("../tray/detachedTray.mjs");
|
||||
try {
|
||||
await notifyTrayReady(parsePort(trayReadyPort, 0), trayReadyToken);
|
||||
} catch {
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
} else {
|
||||
reportReadinessTimeout(dashboardPort, supervisor);
|
||||
@@ -451,29 +505,30 @@ function killTrayIfActive() {
|
||||
async function maybeStartTray(port, apiPort, supervisor) {
|
||||
try {
|
||||
const { initTray, isTraySupported } = await import("../tray/index.mjs");
|
||||
if (!isTraySupported()) return;
|
||||
if (!isTraySupported()) return false;
|
||||
const { default: open } = await import("open").catch(() => ({ default: null }));
|
||||
const dashboardUrl = `${urlScheme}://localhost:${port}`;
|
||||
const tray = await initTray({
|
||||
port,
|
||||
onQuit: () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
},
|
||||
onOpenDashboard: () => open?.(dashboardUrl),
|
||||
onShowLogs: () => {
|
||||
// In-place: open logs stream (best-effort)
|
||||
process.stdout.write(`[omniroute][tray] Logs at: ${dashboardUrl}/logs\n`);
|
||||
},
|
||||
onShowLogs: () => open?.(`${dashboardUrl}/dashboard/logs`),
|
||||
});
|
||||
if (tray) {
|
||||
const { killTray } = await import("../tray/index.mjs");
|
||||
_killTray = killTray;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
// tray is optional — do not fail the server, but surface why it failed so
|
||||
// "--tray shows nothing" is diagnosable instead of silent (#4605).
|
||||
process.stderr.write(`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
"log": "Show server logs inline",
|
||||
"no_recovery": "Disable auto-restart on crash (debugging mode)",
|
||||
"max_restarts": "Max crash restarts within 30s before giving up (default: 2)",
|
||||
"tray": "Show system tray icon (desktop only, opt-in)",
|
||||
"tray": "Start in the system tray (desktop only, opt-in)",
|
||||
"no_tray": "Disable system tray icon",
|
||||
"tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
|
||||
|
||||
@@ -276,6 +276,10 @@ function isAgentSelfMac() {
|
||||
}
|
||||
}
|
||||
|
||||
function isDetachedTrayWorker() {
|
||||
return process.argv.includes("--tray-worker");
|
||||
}
|
||||
|
||||
function enableMac() {
|
||||
const plistDir = join(homedir(), "Library", "LaunchAgents");
|
||||
mkdirSync(plistDir, { recursive: true });
|
||||
@@ -300,7 +304,7 @@ function enableMac() {
|
||||
// If we're already the running agent, launchctl load/unload would SIGTERM us.
|
||||
// The plist is updated on disk and launchd already has us loaded under our own
|
||||
// PID — nothing more to do for the current session.
|
||||
if (isAgentSelfMac()) return existsSync(plistPath);
|
||||
if (isAgentSelfMac() || isDetachedTrayWorker()) return existsSync(plistPath);
|
||||
try {
|
||||
execSync("launchctl load -w " + JSON.stringify(plistPath), { stdio: "ignore" });
|
||||
} catch {}
|
||||
@@ -313,7 +317,7 @@ function disableMac() {
|
||||
// `launchctl unload` sends SIGTERM and a user clicking "Disable Autostart"
|
||||
// from the tray would lose the tray icon instead of just flipping the label.
|
||||
// Removing the plist file is enough to stop the agent at the next login.
|
||||
if (!isAgentSelfMac()) {
|
||||
if (!isAgentSelfMac() && !isDetachedTrayWorker()) {
|
||||
try {
|
||||
execSync("launchctl unload -w " + JSON.stringify(plistPath), { stdio: "ignore" });
|
||||
} catch {}
|
||||
|
||||
176
bin/cli/tray/detachedTray.mjs
Normal file
@@ -0,0 +1,176 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createServer, connect } from "node:net";
|
||||
|
||||
/** Builds arguments for the hidden process that owns the server and tray. */
|
||||
export function buildTrayWorkerArgs({ port, maxRestarts, readyPort, readyToken, tlsCert, tlsKey }) {
|
||||
const args = [
|
||||
"serve",
|
||||
"--tray",
|
||||
"--tray-worker",
|
||||
"--no-open",
|
||||
"--port",
|
||||
String(port),
|
||||
"--max-restarts",
|
||||
String(maxRestarts),
|
||||
"--tray-ready-port",
|
||||
String(readyPort),
|
||||
"--tray-ready-token",
|
||||
readyToken,
|
||||
];
|
||||
if (tlsCert) args.push("--tls-cert", tlsCert);
|
||||
if (tlsKey) args.push("--tls-key", tlsKey);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Builds the platform command that starts the hidden tray worker. */
|
||||
export function buildTrayLaunch({ platform, execPath, cliPath, workerArgs, label }) {
|
||||
if (platform === "darwin") {
|
||||
return {
|
||||
command: "launchctl",
|
||||
args: ["submit", "-l", label, "--", execPath, cliPath, ...workerArgs],
|
||||
options: { stdio: "ignore" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: execPath,
|
||||
args: [cliPath, ...workerArgs],
|
||||
options: { detached: true, stdio: "ignore", windowsHide: true },
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns an error for command modes that conflict with detached tray mode. */
|
||||
export function validateTrayOptions(opts) {
|
||||
if (opts.trayWorker && (!opts.trayReadyPort || !opts.trayReadyToken)) {
|
||||
return "tray worker requires readiness credentials";
|
||||
}
|
||||
if (!opts.tray || opts.trayWorker) return null;
|
||||
if (opts.daemon) return "--tray cannot use --daemon";
|
||||
if (opts.log) return "--tray cannot use --log";
|
||||
if (opts.noRecovery || opts.recovery === false) return "--tray cannot use --no-recovery";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Creates a token-protected loopback server for tray worker readiness. */
|
||||
export async function createTrayReadinessServer(token) {
|
||||
let markReady;
|
||||
const ready = new Promise((resolve) => {
|
||||
markReady = resolve;
|
||||
});
|
||||
const expected = Buffer.from(token);
|
||||
const server = createServer((socket) => {
|
||||
let data = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
if (data.length > 256) socket.destroy();
|
||||
});
|
||||
socket.on("end", () => {
|
||||
const received = Buffer.from(data);
|
||||
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
|
||||
socket.end("ERROR");
|
||||
return;
|
||||
}
|
||||
socket.end("READY");
|
||||
markReady();
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
return {
|
||||
port: address.port,
|
||||
wait(timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("Tray worker did not become ready")),
|
||||
timeoutMs
|
||||
);
|
||||
ready.then(() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
close() {
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Notifies the parent process that the server and tray are ready. */
|
||||
export async function notifyTrayReady(port, token) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = connect({ host: "127.0.0.1", port }, () => socket.end(token));
|
||||
let reply = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk) => {
|
||||
reply += chunk;
|
||||
});
|
||||
socket.on("end", () => {
|
||||
if (reply === "READY") resolve();
|
||||
else reject(new Error("Tray readiness token was rejected"));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Starts a detached tray worker and waits until its server and tray are ready. */
|
||||
export async function startDetachedTray(
|
||||
{ cliPath, port, maxRestarts, tlsCert, tlsKey, timeoutMs = 60000 },
|
||||
{ platform = process.platform, spawnProcess = spawn } = {}
|
||||
) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const readiness = await createTrayReadinessServer(token);
|
||||
const label = `com.omniroute.tray.${process.pid}.${Date.now()}`;
|
||||
const workerArgs = buildTrayWorkerArgs({
|
||||
port,
|
||||
maxRestarts,
|
||||
readyPort: readiness.port,
|
||||
readyToken: token,
|
||||
tlsCert,
|
||||
tlsKey,
|
||||
});
|
||||
const launch = buildTrayLaunch({
|
||||
platform,
|
||||
execPath: process.execPath,
|
||||
cliPath,
|
||||
workerArgs,
|
||||
label,
|
||||
});
|
||||
const child = spawnProcess(launch.command, launch.args, launch.options);
|
||||
const spawnFailure = new Promise((_, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (platform !== "darwin" || code !== 0) {
|
||||
reject(new Error(`Tray worker exited before readiness with code ${code ?? "unknown"}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
if (platform !== "darwin") child.unref?.();
|
||||
try {
|
||||
await Promise.race([readiness.wait(timeoutMs), spawnFailure]);
|
||||
return { platform, pid: child.pid, label: platform === "darwin" ? label : null };
|
||||
} catch (err) {
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
execFileSync("launchctl", ["bootout", `gui/${process.getuid()}/${label}`], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {}
|
||||
} else if (platform === "win32" && child.pid) {
|
||||
try {
|
||||
execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
||||
} catch {}
|
||||
} else if (child.pid) {
|
||||
try {
|
||||
process.kill(child.pid, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
readiness.close();
|
||||
}
|
||||
}
|
||||
@@ -97,9 +97,7 @@ export async function initSystrayUnix(
|
||||
}
|
||||
});
|
||||
|
||||
tray.ready().catch((err) => {
|
||||
process.stderr.write(`[omniroute][tray] systray2 failed: ${err?.message ?? String(err)}\n`);
|
||||
});
|
||||
await tray.ready();
|
||||
|
||||
return tray;
|
||||
}
|
||||
|
||||
1
changelog.d/features/unreleased-detached-cli-tray.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(cli):** run `omniroute serve --tray` as a detached desktop process after server and tray readiness, with graphical login auto-start support.
|
||||
@@ -0,0 +1 @@
|
||||
- fix(sse): default single-target incompatible reasoning to drop for agentic replay — single-target requests to opaque reasoning targets now gracefully strip incompatible plaintext reasoning history instead of returning HTTP 400, matching combo default behavior while preserving operator and per-request overrides ([#10959](https://github.com/diegosouzapw/OmniRoute/issues/10959))
|
||||
1
changelog.d/fixes/codex-max-context-window.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(codex): prefer `max_context_window` over the `context_window` pricing tier as the usable input limit in discovery, and raise the static Codex OAuth catalog to the same usable window so the conservative discovery merge no longer caps live values at the 272K pricing tier
|
||||
@@ -451,7 +451,8 @@
|
||||
"src/shared/components/ModelSelectModal.tsx": 1138,
|
||||
"src/shared/constants/providers/apikey/gateways.ts": 1250
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1082,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1173,
|
||||
"_rebaseline_2026_08_23_11207_aws_polly_fields": "PR #11207 (rafacpti23, draft) own growth: AddApiKeyModal.tsx 1082->1173 (+91, AWS SigV4 credential fields for aws-polly — Access Key ID / Region / optional Session Token blocks with providerText i18n labels, at the existing per-provider form-section chokepoint; the file is the known god-modal with repeated dated rebaselines). Covered by tests/unit/dashboard/aws-polly-connection-modal-fields.test.ts. Owner pre-authorized baseline bumps 2026-08-22.",
|
||||
"_rebaseline_2026_08_22_11156_enter_check_disabled": "PR #11156 (rqzbeh) own growth: AddApiKeyModal.tsx 1080->1082 (+2, Enter keydown handler now mirrors the isCheckDisabled condition — owner-requested post-merge polish from #11056; the rest of the diff is Prettier reflow). Covered by tests/unit/ui/add-api-key-modal-enter-key.test.tsx (jsdom render test, Enter dispatch assertions).",
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051,
|
||||
"src/shared/components/ModelSelectModal.tsx": 1138,
|
||||
|
||||
@@ -43,8 +43,21 @@ x-common: &common
|
||||
- LIVE_WS_ALLOWED_ORIGINS=${LIVE_WS_ALLOWED_ORIGINS:-http://localhost:20128,http://127.0.0.1:20128}
|
||||
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
- NODE_OPTIONS=--max-old-space-size=2048
|
||||
# Codex App-Server transport (provider: codex-app-server). Inert unless the
|
||||
# `codex-app-server` compose profile is up (the sidecar below). Points the app
|
||||
# at the internal sidecar; the capability token is shared via the mounted file.
|
||||
- OMNIROUTE_CODEX_APPSERVER_WS=${OMNIROUTE_CODEX_APPSERVER_WS:-ws://codex-app-server:1456}
|
||||
- OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE=${OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE:-/run/codex-appserver/token}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
# Shared capability token + codex auth for the app-server WS. Only meaningful
|
||||
# when the codex-app-server profile is active. The token dir carries the WS
|
||||
# capability token; the codex home is where the dashboard "Apply auth" writes
|
||||
# ~/.codex/auth.json (getCliConfigPaths("codex") = <home>/.codex; the base
|
||||
# image runs as `node`, so /home/node/.codex) and the SAME volume is mounted
|
||||
# into the sidecar so its `codex app-server` reads the same auth.
|
||||
- codex-appserver-token:/run/codex-appserver
|
||||
- codex-appserver-home:/home/node/.codex
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "healthcheck.mjs"]
|
||||
interval: 30s
|
||||
@@ -290,6 +303,59 @@ services:
|
||||
profiles:
|
||||
- cliproxyapi
|
||||
|
||||
# ── Profile: codex-app-server (Codex CLI app-server sidecar) ──────────
|
||||
# A PLAIN Codex app-server for the `codex-app-server` provider: OmniRoute drives
|
||||
# the Codex CLI's own `codex app-server` over JSON-RPC/WebSocket instead of
|
||||
# replaying a session token to the API. It listens ONLY on the internal compose
|
||||
# network (ws://codex-app-server:1456), guarded by a capability token — it is
|
||||
# NEVER published to the host / internet. The Codex CLI (baked into
|
||||
# omniroute:base) self-manages its OpenAI OAuth via the shared ~/.codex volume,
|
||||
# which the dashboard "Apply auth" (device-OAuth) writes and this sidecar reads.
|
||||
#
|
||||
# NOTE: this is the GENERIC public sidecar. An operator wanting residential /
|
||||
# UDP egress (via a TUN sidecar) runs that separately as an override; it is
|
||||
# intentionally not shipped here.
|
||||
codex-app-server:
|
||||
image: omniroute:base
|
||||
container_name: omniroute-codex-app-server
|
||||
restart: unless-stopped
|
||||
# Generate the WS capability token on first boot if absent, then run the
|
||||
# app-server. entrypoint is overridden because the base image's default is the
|
||||
# Next.js server.
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
set -e
|
||||
TOKEN_FILE=/run/codex-appserver/token
|
||||
mkdir -p /run/codex-appserver
|
||||
if [ ! -s "$$TOKEN_FILE" ]; then
|
||||
# 32-byte hex capability token; shared with the app via the token volume.
|
||||
TF="$$TOKEN_FILE" node -e 'require("fs").writeFileSync(process.env.TF, require("crypto").randomBytes(32).toString("hex"))' 2>/dev/null || \
|
||||
{ head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$$TOKEN_FILE"; }
|
||||
chmod 600 "$$TOKEN_FILE"
|
||||
fi
|
||||
exec codex app-server \
|
||||
--listen ws://0.0.0.0:1456 \
|
||||
--ws-auth capability-token \
|
||||
--ws-token-file "$$TOKEN_FILE"
|
||||
environment:
|
||||
- CODEX_HOME=/home/node/.codex
|
||||
- RUST_LOG=${CODEX_APPSERVER_RUST_LOG:-warn}
|
||||
volumes:
|
||||
- codex-appserver-token:/run/codex-appserver
|
||||
- codex-appserver-home:/home/node/.codex
|
||||
# No `ports:` — internal-only. Reached at ws://codex-app-server:1456 over the
|
||||
# compose network by the omniroute app.
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "node", "-e", "require('http').get('http://127.0.0.1:1456/readyz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
profiles:
|
||||
- codex-app-server
|
||||
|
||||
volumes:
|
||||
chatgpt-web-codex-browser-data:
|
||||
name: omniroute-chatgpt-web-codex-browser-data
|
||||
@@ -301,3 +367,7 @@ volumes:
|
||||
name: omniroute-qdrant-data
|
||||
bifrost-data:
|
||||
name: omniroute-bifrost-data
|
||||
codex-appserver-token:
|
||||
name: omniroute-codex-appserver-token
|
||||
codex-appserver-home:
|
||||
name: omniroute-codex-appserver-home
|
||||
|
||||
@@ -17,7 +17,7 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr
|
||||
|
||||
Core capabilities:
|
||||
|
||||
- OpenAI-compatible API surface for CLI/tools (338 providers, 100 executors)
|
||||
- OpenAI-compatible API surface for CLI/tools (349 providers, 107 executors)
|
||||
- Request/response translation across provider formats
|
||||
- Model combo fallback (multi-model sequence)
|
||||
- Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers`
|
||||
|
||||
@@ -451,7 +451,7 @@ open-sse/
|
||||
├── types.d.ts
|
||||
├── config/ Provider registries, header profiles, identity, …
|
||||
├── handlers/ Request handlers (chat, embeddings, audio, image, …)
|
||||
├── executors/ 101 provider-specific HTTP executors
|
||||
├── executors/ 107 provider-specific HTTP executors
|
||||
├── translator/ Format conversion (OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro)
|
||||
├── transformer/ Responses API ↔ Chat Completions stream transformer
|
||||
├── services/ 80+ service modules (combos, fallback, quotas, identity, …)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg viewBox="0 0 1200 350" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Animated terminal demoing the OmniRoute CLI: omniroute providers list (350 providers registered, anthropic, codex, glm, kimi shown active), omniroute combo list (always-on priority, cost-saver, fusion-panel, context-relay) and omniroute health (healthy, 18412 requests in 24h, p95 412ms, circuit breakers 24 closed, 1 half-open, 0 open), cycling over the 80+ command surface: providers, oauth, keys, combo, nodes, models, cache, compression, cost, usage, quota, health, resilience, telemetry, logs, audit, mcp, a2a, cloud, memory, skills, eval, doctor, repl, tunnel, backup, sync, webhooks, policy, pricing, translator, simulate and more.">
|
||||
<svg viewBox="0 0 1200 350" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Animated terminal demoing the OmniRoute CLI: omniroute providers list (351 providers registered, anthropic, codex, glm, kimi shown active), omniroute combo list (always-on priority, cost-saver, fusion-panel, context-relay) and omniroute health (healthy, 18412 requests in 24h, p95 412ms, circuit breakers 24 closed, 1 half-open, 0 open), cycling over the 80+ command surface: providers, oauth, keys, combo, nodes, models, cache, compression, cost, usage, quota, health, resilience, telemetry, logs, audit, mcp, a2a, cloud, memory, skills, eval, doctor, repl, tunnel, backup, sync, webhooks, policy, pricing, translator, simulate and more.">
|
||||
<desc>Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen.</desc>
|
||||
<defs><clipPath id="tickerClip"><rect x="12" y="304" width="1176" height="40"/></clipPath><clipPath id="tw0"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;31;61;92;122;153;184;214;245;245" keyTimes="0;0.012;0.018;0.024;0.030;0.036;0.042;0.048;0.054;1" dur="18s" repeatCount="indefinite"/></rect></clipPath><clipPath id="tw1"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;26;51;76;102;128;153;178;204;204" keyTimes="0;0.348;0.351;0.357;0.363;0.369;0.375;0.381;0.387;1" dur="18s" repeatCount="indefinite"/></rect></clipPath><clipPath id="tw2"><rect x="64" y="46" height="26" width="0"><animate attributeName="width" calcMode="discrete" values="0;20;41;61;82;102;122;143;163;163" keyTimes="0;0.678;0.684;0.690;0.696;0.702;0.708;0.714;0.720;1" dur="18s" repeatCount="indefinite"/></rect></clipPath></defs>
|
||||
<rect width="1200" height="350" fill="#0d1117"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg viewBox="0 0 1200 780" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison table: OmniRoute versus 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute is the only one with the full set: 350 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, a built-in MCP server with 110 tools, A2A protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, desktop/Termux/PWA, 43 UI locales and 100% MIT self-hosted. 9router has free providers, RTK compression and translation but no MCP, A2A, memory, guardrails, cloud agents or stealth. OpenRouter is a hosted SaaS with 400+ models, guardrails and a hosted MCP but is not self-hosted and lacks A2A, memory, cloud agents and stealth. CLIProxyAPI is a light OAuth proxy with two routing strategies. LiteLLM has 100+ providers, A2A and extensive guardrails but no memory, compression, free tier, stealth or cloud agents.">
|
||||
<svg viewBox="0 0 1200 780" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison table: OmniRoute versus 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute is the only one with the full set: 351 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, a built-in MCP server with 110 tools, A2A protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, desktop/Termux/PWA, 43 UI locales and 100% MIT self-hosted. 9router has free providers, RTK compression and translation but no MCP, A2A, memory, guardrails, cloud agents or stealth. OpenRouter is a hosted SaaS with 400+ models, guardrails and a hosted MCP but is not self-hosted and lacks A2A, memory, cloud agents and stealth. CLIProxyAPI is a light OAuth proxy with two routing strategies. LiteLLM has 100+ providers, A2A and extensive guardrails but no memory, compression, free tier, stealth or cloud agents.">
|
||||
<desc>Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses.</desc>
|
||||
<defs>
|
||||
<pattern id="gC" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M 32 0 L 0 0 0 32" fill="none" stroke="#ffffff" stroke-opacity="0.05" stroke-width="1"/></pattern>
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint, 350 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 350 providers in milliseconds, quota out means the next provider takes over with zero downtime. Save up to 95 percent of tokens: RTK plus Caveman stacked compression cuts 15 to 95 percent of eligible tokens, about 89 percent average on tool-heavy sessions. Zero dollars to start: 90+ providers with a free tier, 56 free forever — Qoder, Pollinations, Cloudflare, SiliconFlow — no card needed. Every tool works: 33 coding agents including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation — point any tool at /v1 and it just works. Production-grade: circuit breakers, TLS stealth, MCP with 110 tools, A2A, memory, guardrails, evals — 25,000+ tests.">
|
||||
<svg viewBox="0 0 1200 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The OmniRoute promise: one endpoint, 351 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 351 providers in milliseconds, quota out means the next provider takes over with zero downtime. Save up to 95 percent of tokens: RTK plus Caveman stacked compression cuts 15 to 95 percent of eligible tokens, about 89 percent average on tool-heavy sessions. Zero dollars to start: 90+ providers with a free tier, 56 free forever — Qoder, Pollinations, Cloudflare, SiliconFlow — no card needed. Every tool works: 33 coding agents including Claude Code, Codex, Cursor, Cline, Copilot and Antigravity through one config. One endpoint: OpenAI, Claude, Gemini and Responses API translation — point any tool at /v1 and it just works. Production-grade: circuit breakers, TLS stealth, MCP with 110 tools, A2A, memory, guardrails, evals — 25,000+ tests.">
|
||||
<desc>Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle.</desc>
|
||||
<defs>
|
||||
<pattern id="gridPaperP" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
@@ -21,7 +21,7 @@
|
||||
<line x1="150" y1="53" x2="1160" y2="53" stroke="#232b38" stroke-width="1.5"/>
|
||||
</g>
|
||||
<g>
|
||||
<text x="40" y="100" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="23" font-weight="600" fill="#c9d1d9">One endpoint. <tspan fill="#a78bfa" font-weight="800">350 providers.</tspan> Never stop building — OmniRoute picks <tspan fill="#7ee787" font-weight="700">the cheapest one that works</tspan>.</text>
|
||||
<text x="40" y="100" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="23" font-weight="600" fill="#c9d1d9">One endpoint. <tspan fill="#a78bfa" font-weight="800">351 providers.</tspan> Never stop building — OmniRoute picks <tspan fill="#7ee787" font-weight="700">the cheapest one that works</tspan>.</text>
|
||||
</g>
|
||||
|
||||
<g font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif">
|
||||
@@ -38,7 +38,7 @@
|
||||
<line x1="3.9" y1="3.9" x2="18.1" y2="18.1"/>
|
||||
</g>
|
||||
<text x="102" y="170" font-size="18" font-weight="800" fill="#74b9ff">Never hit limits</text>
|
||||
<text x="66" y="204" font-size="13.5" fill="#a1a1aa">Auto-fallback across 350 providers in</text>
|
||||
<text x="66" y="204" font-size="13.5" fill="#a1a1aa">Auto-fallback across 351 providers in</text>
|
||||
<text x="66" y="226" font-size="13.5" fill="#a1a1aa">milliseconds. Quota out? The next provider</text>
|
||||
<text x="66" y="248" font-size="13.5" fill="#a1a1aa">takes over — zero downtime.</text>
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 350 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 350 AI providers, 90+ free tiers, about 1.51B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
|
||||
<svg viewBox="0 0 1200 548" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="OmniRoute hero: Never stop coding. Every AI tool to 351 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot and Antigravity into free Claude, GPT and Gemini with auto-fallback. RTK + Caveman stacked compression saves 15 to 95 percent of tokens — about 89 percent average on tool-heavy sessions — so you never hit limits. Stats: 351 AI providers, 90+ free tiers, about 1.51B free tokens per month, 15 to 95 percent token savings, 19 routing strategies, zero dollars to start.">
|
||||
<desc>Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame.</desc>
|
||||
<defs>
|
||||
<pattern id="gridPaperH" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
@@ -28,7 +28,7 @@
|
||||
<text x="48" y="138" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="60" font-weight="800" fill="#e9edf3">Never stop coding<tspan fill="#a855f7">.</tspan></text>
|
||||
|
||||
<!-- subheadline -->
|
||||
<text x="48" y="184" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="25" font-weight="600" fill="#c9d1d9">Every AI tool → <tspan fill="#a78bfa" font-weight="800">350 providers</tspan> — <tspan fill="#7ee787" font-weight="800">90+ free</tspan> — through one endpoint.</text>
|
||||
<text x="48" y="184" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="25" font-weight="600" fill="#c9d1d9">Every AI tool → <tspan fill="#a78bfa" font-weight="800">351 providers</tspan> — <tspan fill="#7ee787" font-weight="800">90+ free</tspan> — through one endpoint.</text>
|
||||
|
||||
<!-- plug line -->
|
||||
<text x="48" y="222" font-family="Inter, 'Segoe UI', Arial, Helvetica, system-ui, sans-serif" font-size="16.5" fill="#a1a1aa">Claude Code · Codex · Cursor · Cline · Copilot · Antigravity  →  <tspan fill="#7ee787" font-weight="700">FREE</tspan> Claude / GPT / Gemini · auto-fallback</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
89
docs/guides/CODEX-APP-SERVER-PROVIDER.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: "OpenAI Codex (App-Server) provider"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-22
|
||||
---
|
||||
|
||||
# OpenAI Codex — App-Server provider (`codex-app-server`)
|
||||
|
||||
OmniRoute exposes **two** ways to use OpenAI Codex:
|
||||
|
||||
| Provider | How it talks to OpenAI | Usage caveat |
|
||||
|---|---|---|
|
||||
| **`codex`** | Replays your ChatGPT/OpenAI OAuth token directly to the Responses API | **Yes** — the official session is not authorized for proxy/router use |
|
||||
| **`codex-app-server`** | Drives the **Codex CLI's own `codex app-server`** over JSON-RPC/WebSocket; the CLI owns and self-refreshes its OAuth (`~/.codex/auth.json`) exactly like an interactive `codex` session | **No** — OmniRoute never replays a token to the API |
|
||||
|
||||
Because `codex-app-server` never replays a token, it does not carry the
|
||||
session-replay usage caveat. It does require a **Codex CLI reachable at the
|
||||
configured app-server URL**, and that CLI must be **signed in**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
```
|
||||
┌─ OmniRoute app ─────────────────┐ ┌─ codex-app-server sidecar ─────────┐
|
||||
│ CodexAppServerExecutor │ WS │ codex app-server │
|
||||
│ ws://codex-app-server:1456 ─────┼───────▶│ --listen ws://0.0.0.0:1456 │
|
||||
│ (+ capability token) │ JSON │ --ws-auth capability-token │
|
||||
│ │ RPC │ self-manages OpenAI OAuth │
|
||||
└──────────────────────────────────┘ │ (~/.codex/auth.json, auto-refresh) │
|
||||
│ shares (compose volumes) └─────────────────────────────────────┘
|
||||
▼
|
||||
codex-appserver-token → the WS capability token (both mount it)
|
||||
codex-appserver-home → ~/.codex (auth.json written by the dashboard,
|
||||
read by the sidecar's codex app-server)
|
||||
```
|
||||
|
||||
- The sidecar listens **only** on the internal compose network
|
||||
(`ws://codex-app-server:1456`) behind a capability token. It is **never**
|
||||
published to the host or internet.
|
||||
- The Codex CLI is baked into `omniroute:base`, so no codex install is needed on
|
||||
the host or the user's machine when you run the sidecar.
|
||||
|
||||
## 2. Bring it up
|
||||
|
||||
```bash
|
||||
# Start the stack WITH the codex app-server sidecar profile:
|
||||
docker compose --profile base --profile codex-app-server up -d
|
||||
# (podman: podman compose --profile base --profile codex-app-server up -d)
|
||||
```
|
||||
|
||||
The sidecar mints its WS capability token on first boot (into the shared
|
||||
`codex-appserver-token` volume) and the app reads the same token via
|
||||
`OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE`. No manual token wiring needed.
|
||||
|
||||
## 3. Connect + sign in
|
||||
|
||||
1. In the dashboard, add a connection for **OpenAI Codex (App-Server)**. No API
|
||||
key or token is required (it's a no-auth provider — the sidecar owns auth).
|
||||
2. If the sidecar's Codex CLI is **not yet signed in**, the connection health
|
||||
check reports *"running but not signed in"* (not a red auth error). Use
|
||||
**Sign in with ChatGPT**: this runs the standard Codex device-OAuth in your
|
||||
browser and then writes `~/.codex/auth.json` into the shared volume via
|
||||
**Apply auth** (the same one login serves both the `codex` and
|
||||
`codex-app-server` providers).
|
||||
3. Once signed in, the health check goes green (it verifies both `/readyz` **and**
|
||||
`account/read` — i.e. up *and* authenticated) and turns work.
|
||||
|
||||
The dashboard never clobbers a healthy existing `~/.codex/auth.json` — it writes
|
||||
only when the file is absent or its token is stale (a backup is always taken).
|
||||
|
||||
## 4. Deployment scenarios
|
||||
|
||||
- **Operator with an already-authenticated Codex CLI** — mount your host
|
||||
`~/.codex` into the sidecar (`codex-appserver-home`) and skip the sign-in step.
|
||||
- **Public user, no codex installed locally** — irrelevant: the sidecar has the
|
||||
CLI. The user only authenticates through the dashboard.
|
||||
- **Bare-metal OmniRoute (no sidecar, host codex)** — point
|
||||
`OMNIROUTE_CODEX_APPSERVER_WS` at your own `codex app-server` and ensure the
|
||||
host codex is signed in; the "codex not installed" hint appears if the binary
|
||||
is missing.
|
||||
|
||||
## 5. Residential / UDP egress (operator extra, not shipped)
|
||||
|
||||
The generic sidecar above egresses over the container's normal network. An
|
||||
operator who needs Codex traffic to egress via a **residential exit** (e.g. a TUN
|
||||
tailscale sidecar carrying TCP + UDP/QUIC) runs that as a separate compose
|
||||
override; it is intentionally **not** part of the shipped `codex-app-server`
|
||||
profile. See the internal operations runbook for that setup.
|
||||
@@ -357,6 +357,49 @@ omniroute --port 3000
|
||||
|
||||
The CLI automatically loads `.env` from `~/.omniroute/.env` or `./.env`.
|
||||
|
||||
### Tray mode
|
||||
|
||||
Start OmniRoute in the system tray:
|
||||
|
||||
```bash
|
||||
omniroute serve --tray
|
||||
```
|
||||
|
||||
The command returns after the server and tray are ready.
|
||||
|
||||
The server continues without the terminal.
|
||||
|
||||
Tray mode supports macOS, Windows, and graphical Linux sessions. Tray mode does not open the dashboard automatically.
|
||||
|
||||
Use the tray menu for these actions:
|
||||
|
||||
- Open the dashboard.
|
||||
- Open `/dashboard/logs`.
|
||||
- Change auto-start.
|
||||
- Stop OmniRoute.
|
||||
|
||||
Do not combine `--tray` with these options:
|
||||
|
||||
- `--daemon`
|
||||
- `--log`
|
||||
- `--no-recovery`
|
||||
|
||||
These modes require different process ownership.
|
||||
|
||||
Enable startup at the next machine login:
|
||||
|
||||
```bash
|
||||
omniroute autostart enable
|
||||
```
|
||||
|
||||
Auto-start uses tray mode on macOS, Windows, and graphical Linux sessions. Headless Linux uses the existing systemd user service.
|
||||
|
||||
Disable startup at login:
|
||||
|
||||
```bash
|
||||
omniroute autostart disable
|
||||
```
|
||||
|
||||
### Uninstalling
|
||||
|
||||
When you no longer need OmniRoute, we provide two quick scripts for a clean removal:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
---
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -176,7 +176,7 @@ All tools that appear in `/dashboard/cli-code`. Those with `baseUrlSupport: none
|
||||
Tools with `baseUrlSupport: "partial"` show a badge "⚠ Base URL parcial" in the dashboard card.
|
||||
---
|
||||
|
||||
## 2. CLI Agents Catalog (8 tools)
|
||||
## 2. CLI Agents Catalog (9 tools)
|
||||
|
||||
Autonomous agents that appear in `/dashboard/cli-agents`:
|
||||
|
||||
@@ -190,6 +190,7 @@ Autonomous agents that appear in `/dashboard/cli-agents`:
|
||||
| agent-deck | Agent Deck | asheshgoplani (OSS) | full | false |
|
||||
| omp | Oh My Pi | OSS | full | true |
|
||||
| letta | Letta CLI | Letta | full | false |
|
||||
| prime-agent | Prime Agent | Prime Intellect (OSS) | full | false |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -737,6 +737,12 @@ REQUEST_TIMEOUT_MS (global override)
|
||||
| `OMNIROUTE_AGENT_GOAL_READINESS_MAX_TIMEOUT_MS` | `600000` | Maximum first-event readiness window for detected `/goal` agent runs or requests forced with `x-omniroute-agent-goal`. |
|
||||
| `OMNIROUTE_AGENT_GOAL_STREAM_RECOVERY` | `true` | Enable early stream recovery automatically for detected `/goal` agent runs. Set `false`/`0`/`off` to disable the goal-specific opt-in. This can only ADD recovery on top of the operator default — it never overrides an explicit `STREAM_RECOVERY_ENABLED`/DB settings opt-out. |
|
||||
| `OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS` | `true` | Strip non-standard `codex.*` SSE events (e.g. `codex.rate_limits`) that break the OpenAI SDK's `responses.stream()` with a 502. Default ON (#11014). Set `0`/`false`/`no`/`off` to forward them. |
|
||||
| `OMNIROUTE_CODEX_APPSERVER_WS` | _(unset)_ | Opt-in Codex app-server transport. WebSocket endpoint (`ws://`/`wss://`) of a local `codex app-server` sidecar. When set together with a token, Codex requests are routed over JSON-RPC to the sidecar instead of the HTTP Responses API. Also settable per-connection via `providerSpecificData.codexAppServerUrl`. Used by `open-sse/executors/codex/appServerConfig.ts`. |
|
||||
| `OMNIROUTE_CODEX_APPSERVER_WS_TOKEN` | _(unset)_ | Inline capability/bearer token presented to the app-server. Per-connection override: `providerSpecificData.codexAppServerToken`. |
|
||||
| `OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE` | _(unset)_ | Path to a file holding the app-server capability token (from `codex app-server --ws-token-file`). Used when `OMNIROUTE_CODEX_APPSERVER_WS_TOKEN` is unset. Per-connection override: `providerSpecificData.codexAppServerTokenFile`. |
|
||||
| `OMNIROUTE_CODEX_APPSERVER_CWD` | `/tmp` | Working directory the app-server turn runs in. Per-connection override: `providerSpecificData.codexAppServerCwd`. |
|
||||
| `OMNIROUTE_CODEX_APPSERVER_APPROVAL` | _(unset)_ | Approval policy passed to the app-server turn (e.g. `never`, `on-request`). Per-connection override: `providerSpecificData.codexAppServerApprovalPolicy`. |
|
||||
| `OMNIROUTE_CODEX_APPSERVER_SANDBOX` | _(unset)_ | Sandbox policy passed to the app-server turn (e.g. `read-only`, `workspace-write`, `danger-full-access`). Per-connection override: `providerSpecificData.codexAppServerSandbox`. |
|
||||
| `FETCH_HEADERS_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive response headers. |
|
||||
| `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS` | `30000` (30s) | Maximum response-start wait (ms) for each direct no-proxy attempt. A timeout retries once on a fresh socket; set `0` to disable the bound and retain the previous behavior. |
|
||||
| `FETCH_BODY_TIMEOUT_MS` | = `FETCH_TIMEOUT_MS` | Time to receive the full response body. |
|
||||
|
||||
@@ -10,7 +10,7 @@ lastUpdated: 2026-08-23
|
||||
> Regenerate with: `npm run gen:provider-reference`
|
||||
> **Last generated:** 2026-08-23
|
||||
|
||||
Total providers: **350**. See category breakdown below.
|
||||
Total providers: **351**. See category breakdown below.
|
||||
|
||||
## Categories
|
||||
|
||||
@@ -34,7 +34,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
|
||||
---
|
||||
|
||||
## No-auth Providers (no key required) (12)
|
||||
## No-auth Providers (no key required) (13)
|
||||
|
||||
| ID | Alias | Name | Tags | Website | Notes | Tool calling |
|
||||
|----|-------|------|------|---------|-------|--------------|
|
||||
@@ -42,6 +42,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
| `auggie` | `aug` | Augment (Auggie CLI) | No-auth | [link](https://augmentcode.com) | No API key stored by OmniRoute. Install the Auggie CLI and run `auggie login` on this machine, then OmniRoute spawns it locally for each request. | — |
|
||||
| `chipotle` | `pepper` | Chipotle Pepper AI (Free) | No-auth | [link](https://amelia.chipotle.com) | No credentials required. Uses Chipotle's public support chatbot via reverse-engineered SockJS/STOMP protocol. | — |
|
||||
| `cloudflare-playground` | `cfp` | Cloudflare AI Playground | No-auth | [link](https://playground.ai.cloudflare.com) | No credentials required — anonymous browser sessions over a reverse-engineered cf_agent WebSocket protocol (Playwright transport). | — |
|
||||
| `codex-app-server` | `cxa` | OpenAI Codex (App-Server) | No-auth | [link](https://developers.openai.com/codex/cli) | No token stored by OmniRoute. The Codex CLI app-server manages its own ChatGPT sign-in (~/.codex/auth.json, auto-refreshed). Use “Sign in with ChatGPT” if the CLI is not yet authenticated. | — |
|
||||
| `devin-cli-agentic` | `dva` | Devin CLI Agentic Bridge | No-auth | [link](https://docs.devin.ai/work-with-devin/devin-cli) | Authentication is owned by the official Devin CLI in its isolated bridge volume. | emulated |
|
||||
| `duckduckgo-web` | `ddgw` | DuckDuckGo AI Chat | No-auth | [link](https://duckduckgo.com/duckchat) | No credentials required — DuckDuckGo AI Chat is anonymous and free. | emulated |
|
||||
| `felo-web` | `felo` | Felo | No-auth | [link](https://felo.ai) | No credentials required — Felo is a free, no-signup chat/search aggregator. | — |
|
||||
@@ -438,7 +439,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
|
||||
- Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts)
|
||||
- Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts)
|
||||
- Executors: [`open-sse/executors/`](../../open-sse/executors/) (107 implementations)
|
||||
- Executors: [`open-sse/executors/`](../../open-sse/executors/) (108 implementations)
|
||||
- Translators: [`open-sse/translator/`](../../open-sse/translator/)
|
||||
|
||||
## See Also
|
||||
|
||||
4
llm.txt
@@ -1,6 +1,6 @@
|
||||
# OmniRoute
|
||||
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 350 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 351 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **350 AI providers** with automatic format translation
|
||||
- **351 AI providers** with automatic format translation
|
||||
- **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible)
|
||||
- **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline
|
||||
- **4-tier fallback**: Subscription → API Key → Cheap → Free
|
||||
|
||||
@@ -210,6 +210,7 @@ import { baiduProvider } from "./registry/baidu/index.ts";
|
||||
import { pollinationsProvider } from "./registry/pollinations/index.ts";
|
||||
import { veoaifree_webProvider } from "./registry/veoaifree-web/index.ts";
|
||||
import { codexProvider } from "./registry/codex/index.ts";
|
||||
import { codexAppServerProvider } from "./registry/codex-app-server/index.ts";
|
||||
import { veniceProvider } from "./registry/venice/index.ts";
|
||||
import { kiroProvider } from "./registry/kiro/index.ts";
|
||||
import { openadapterProvider } from "./registry/openadapter/index.ts";
|
||||
@@ -476,6 +477,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
pollinations: pollinationsProvider,
|
||||
"veoaifree-web": veoaifree_webProvider,
|
||||
codex: codexProvider,
|
||||
"codex-app-server": codexAppServerProvider,
|
||||
venice: veniceProvider,
|
||||
kiro: kiroProvider,
|
||||
byteplus: byteplusProvider,
|
||||
|
||||
36
open-sse/config/providers/registry/codex-app-server/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
import { codexProvider } from "../codex/index.ts";
|
||||
|
||||
/**
|
||||
* OpenAI Codex — App-Server transport (sibling of the `codex` provider).
|
||||
*
|
||||
* This provider drives the Codex CLI's own `codex app-server` over JSON-RPC/
|
||||
* WebSocket (executor: "codex-app-server"). Unlike the `codex` provider — which
|
||||
* replays the user's ChatGPT/OpenAI OAuth token directly to the Responses API —
|
||||
* the app-server process OWNS and self-refreshes its OpenAI auth
|
||||
* (~/.codex/auth.json), exactly like an interactive `codex` session. OmniRoute
|
||||
* never receives or replays a token, so there is no `authType: "oauth"` and no
|
||||
* usage-caveat: `authType: "none"`.
|
||||
*
|
||||
* The connection target (ws:// URL + capability token) is supplied per-connection
|
||||
* via providerSpecificData (codexAppServerUrl / codexAppServerToken[File]) and
|
||||
* resolved by resolveAppServerConfig — NOT from `baseUrl` below, which is a
|
||||
* documentation sentinel only.
|
||||
*
|
||||
* Models are shared with the `codex` provider (same underlying ChatGPT Codex
|
||||
* backend), imported from codexProvider so the two stay in lockstep.
|
||||
*/
|
||||
export const codexAppServerProvider: RegistryEntry = {
|
||||
id: "codex-app-server",
|
||||
alias: "cxa",
|
||||
format: "openai-responses",
|
||||
executor: "codex-app-server",
|
||||
// Sentinel: the executor dials the WebSocket app-server URL from
|
||||
// providerSpecificData, not this baseUrl. Kept for catalog/debug display.
|
||||
baseUrl: "codex-app-server://cli/websocket",
|
||||
reasoningTransport: "opaque",
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
defaultContextLength: 400000,
|
||||
models: [...codexProvider.models],
|
||||
};
|
||||
@@ -284,14 +284,21 @@ export const GPT_5_6_API_CAPABILITIES = {
|
||||
maxOutputTokens: 128000,
|
||||
} as const;
|
||||
|
||||
// Codex OAuth catalog limits. The live OAuth `/codex/models` endpoint reports
|
||||
// `context_window` (~272K, the first pricing tier) alongside
|
||||
// `max_context_window` (~872K, the real usable window); requests past the
|
||||
// pricing tier succeed upstream (verified: gpt-5.6-luna-xhigh served 380-390K
|
||||
// input tokens with HTTP 200). The static catalog must advertise the usable
|
||||
// window so the conservative discovery merge (`Math.min`) does not cap the
|
||||
// live value at the pricing tier.
|
||||
export const GPT_5_6_CODEX_CAPABILITIES = {
|
||||
targetFormat: "openai-responses",
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
supportsXHighEffort: true,
|
||||
contextLength: 272000,
|
||||
maxInputTokens: 272000,
|
||||
contextLength: 872000,
|
||||
maxInputTokens: 872000,
|
||||
maxOutputTokens: 128000,
|
||||
} as const;
|
||||
|
||||
|
||||
448
open-sse/executors/codex-app-server.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import {
|
||||
bridgeToResponsesSSE,
|
||||
buildResponseJSON,
|
||||
} from "../vendor/codex-chatgpt-web/bridge.ts";
|
||||
import { AsyncEventQueue } from "../vendor/codex-chatgpt-web/event-queue.ts";
|
||||
import type { AdapterEvent } from "../vendor/codex-chatgpt-web/types.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult } from "./base.ts";
|
||||
import {
|
||||
CodexAppServerClient,
|
||||
type CodexAppServerClientOptions,
|
||||
} from "./codex/appServerClient.ts";
|
||||
import { resolveAppServerConfig, type CodexAppServerConfig } from "./codex/appServerConfig.ts";
|
||||
import {
|
||||
translateNotification,
|
||||
translateToolCall,
|
||||
type DynamicToolCallLike,
|
||||
} from "./codex/appServerEvents.ts";
|
||||
|
||||
const JSON_HEADERS = { "Content-Type": "application/json" };
|
||||
const SSE_HEADERS = {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
};
|
||||
|
||||
/** A single text UserInput as accepted by turn/start (text_elements is required). */
|
||||
interface CodexTextUserInput {
|
||||
type: "text";
|
||||
text: string;
|
||||
text_elements: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten an OpenAI Responses request body into the plain prompt text the
|
||||
* app-server turn expects. The body's `input` is a string, a single message item,
|
||||
* or an array of message items with `content` parts; we concatenate the user-facing
|
||||
* text. This is intentionally lossless-enough for a text turn (images/tool parts are
|
||||
* out of scope for the initial app-server transport).
|
||||
*/
|
||||
export function extractPromptText(body: unknown): string {
|
||||
if (!body || typeof body !== "object") return "";
|
||||
const input = (body as Record<string, unknown>).input;
|
||||
if (typeof input === "string") return input;
|
||||
if (input == null) return "";
|
||||
const items = Array.isArray(input) ? input : [input];
|
||||
const chunks: string[] = [];
|
||||
for (const item of items) {
|
||||
collectText(item, chunks);
|
||||
}
|
||||
return chunks.join("\n").trim();
|
||||
}
|
||||
|
||||
function collectText(item: unknown, out: string[]): void {
|
||||
if (typeof item === "string") {
|
||||
if (item.length > 0) out.push(item);
|
||||
return;
|
||||
}
|
||||
if (!item || typeof item !== "object") return;
|
||||
const rec = item as Record<string, unknown>;
|
||||
if (typeof rec.text === "string" && rec.text.length > 0) {
|
||||
out.push(rec.text);
|
||||
return;
|
||||
}
|
||||
const content = rec.content;
|
||||
if (typeof content === "string") {
|
||||
if (content.length > 0) out.push(content);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (part && typeof part === "object") {
|
||||
const text = (part as Record<string, unknown>).text;
|
||||
if (typeof text === "string" && text.length > 0) out.push(text);
|
||||
} else if (typeof part === "string" && part.length > 0) {
|
||||
out.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional reasoning effort carried on the Responses body (`reasoning.effort`). */
|
||||
function extractEffort(body: unknown): string | undefined {
|
||||
if (!body || typeof body !== "object") return undefined;
|
||||
const reasoning = (body as Record<string, unknown>).reasoning;
|
||||
if (reasoning && typeof reasoning === "object") {
|
||||
const effort = (reasoning as Record<string, unknown>).effort;
|
||||
if (typeof effort === "string" && effort.length > 0) return effort;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A codex app-server DynamicToolSpec (experimental-api) advertised on thread/start. */
|
||||
interface DynamicToolFunctionSpec {
|
||||
type: "function";
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AppServerToolMaps {
|
||||
/** wireName -> {namespace, name} for restoring MCP namespaced calls in the bridge. */
|
||||
namespace: Map<string, { namespace: string; name: string }>;
|
||||
/** wireNames the bridge must relay as custom_tool_call (freeform, e.g. apply_patch). */
|
||||
freeform: Set<string>;
|
||||
/** wireNames the bridge must relay as tool_search_call. */
|
||||
toolSearch: Set<string>;
|
||||
/** DynamicToolSpecs to advertise to codex on thread/start (experimental-api). */
|
||||
specs: DynamicToolFunctionSpec[];
|
||||
}
|
||||
|
||||
const EMPTY_OBJECT_SCHEMA: Record<string, unknown> = { type: "object", properties: {} };
|
||||
const FREEFORM_INPUT_SCHEMA: Record<string, unknown> = {
|
||||
type: "object",
|
||||
properties: { input: { type: "string", description: "Raw tool input." } },
|
||||
required: ["input"],
|
||||
};
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the bridge tool maps + the codex dynamicTools specs from the harness's
|
||||
* Responses `tools` array. This mirrors chatgpt-web-codex.ts:toolMaps() /
|
||||
* parser.ts:buildTools(): every harness tool is exposed to codex FLAT under its
|
||||
* wire name ("<namespace>__<name>" for MCP tools) so the round-trip is
|
||||
* namespace-preserving (codex echoes the call via item/tool/call; the bridge
|
||||
* restores {namespace, name} from `toolNsMap`). Custom (freeform) and tool_search
|
||||
* tools are tracked so the bridge relays them as custom_tool_call / tool_search_call.
|
||||
*/
|
||||
function buildAppServerToolMaps(body: unknown): AppServerToolMaps {
|
||||
const namespace = new Map<string, { namespace: string; name: string }>();
|
||||
const freeform = new Set<string>();
|
||||
const toolSearch = new Set<string>();
|
||||
const specs: DynamicToolFunctionSpec[] = [];
|
||||
|
||||
const rec = asRecord(body);
|
||||
const tools = rec && Array.isArray(rec.tools) ? (rec.tools as unknown[]) : [];
|
||||
|
||||
const pushFn = (name: string, description: string, inputSchema: Record<string, unknown>) => {
|
||||
specs.push({ type: "function", name, description, inputSchema });
|
||||
};
|
||||
|
||||
for (const raw of tools) {
|
||||
const t = asRecord(raw);
|
||||
if (!t) continue;
|
||||
const type = t.type;
|
||||
const desc = typeof t.description === "string" ? t.description : "";
|
||||
|
||||
if (type === "function" && typeof t.name === "string") {
|
||||
const wireName = t.name;
|
||||
pushFn(wireName, desc, asRecord(t.parameters) ?? EMPTY_OBJECT_SCHEMA);
|
||||
} else if (type === "namespace" && Array.isArray(t.tools) && typeof t.name === "string") {
|
||||
const ns = t.name;
|
||||
for (const innerRaw of t.tools as unknown[]) {
|
||||
const inner = asRecord(innerRaw);
|
||||
if (inner && inner.type === "function" && typeof inner.name === "string") {
|
||||
const wireName = `${ns}__${inner.name}`;
|
||||
namespace.set(wireName, { namespace: ns, name: inner.name });
|
||||
const innerDesc = typeof inner.description === "string" ? inner.description : "";
|
||||
pushFn(wireName, innerDesc, asRecord(inner.parameters) ?? EMPTY_OBJECT_SCHEMA);
|
||||
}
|
||||
}
|
||||
} else if (type === "custom" && typeof t.name === "string") {
|
||||
const wireName = t.name;
|
||||
freeform.add(wireName);
|
||||
pushFn(wireName, desc, FREEFORM_INPUT_SCHEMA);
|
||||
} else if (type === "tool_search") {
|
||||
const wireName = "tool_search";
|
||||
toolSearch.add(wireName);
|
||||
pushFn(
|
||||
wireName,
|
||||
desc || "Search for additional tools to load for the next turn.",
|
||||
asRecord(t.parameters) ?? {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" }, limit: { type: "number" } },
|
||||
required: ["query"],
|
||||
}
|
||||
);
|
||||
} else if (
|
||||
typeof t.name === "string" &&
|
||||
type !== "web_search" &&
|
||||
type !== "image_generation" &&
|
||||
type !== "web_search_preview"
|
||||
) {
|
||||
// Any other named, client-executed tool → pass through as a function so the
|
||||
// routed model can call it; the bridge relays its call as a function_call.
|
||||
pushFn(t.name, desc, asRecord(t.parameters) ?? EMPTY_OBJECT_SCHEMA);
|
||||
}
|
||||
// web_search / image_generation are OpenAI-hosted server-side tools — not relayable.
|
||||
}
|
||||
|
||||
return { namespace, freeform, toolSearch, specs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Executor for the Codex app-server WS transport. Drives one turn against a local
|
||||
* `codex app-server` over JSON-RPC and re-emits its notifications as OpenAI
|
||||
* Responses SSE via the shared bridge.
|
||||
*
|
||||
* Errors are delivered IN-BAND (an `error` AdapterEvent → `response.failed` SSE
|
||||
* frame for streaming, or an error field in the JSON body for non-streaming),
|
||||
* never thrown out of execute().
|
||||
*/
|
||||
export class CodexAppServerExecutor extends BaseExecutor {
|
||||
private readonly clientOptions: CodexAppServerClientOptions;
|
||||
|
||||
/**
|
||||
* @param clientOptions transport options (websocketFn, timeouts).
|
||||
* @param providerId which provider identity this executor reports as. Defaults
|
||||
* to "codex" so the existing per-connection `codexTransport==="app-server"`
|
||||
* flag path (routed through CodexExecutor for the `codex` provider) keeps its
|
||||
* original identity. The first-class `codex-app-server` sibling passes
|
||||
* "codex-app-server" so logs/quota scoping and the golden executor map reflect
|
||||
* the real provider. Falls back to PROVIDERS.codex when the sibling registry
|
||||
* entry is not present (defensive; both share the codex backend).
|
||||
*/
|
||||
constructor(clientOptions: CodexAppServerClientOptions = {}, providerId = "codex") {
|
||||
super(providerId, PROVIDERS[providerId] ?? PROVIDERS.codex);
|
||||
this.clientOptions = clientOptions;
|
||||
}
|
||||
|
||||
override async execute(input: ExecuteInput): Promise<ExecutorExecuteResult> {
|
||||
const psd = input.credentials?.providerSpecificData;
|
||||
const config = resolveAppServerConfig(psd);
|
||||
if (!config) {
|
||||
return errorResponse(
|
||||
503,
|
||||
"Codex app-server transport is not configured (missing url or token)",
|
||||
"codex_app_server_unconfigured"
|
||||
);
|
||||
}
|
||||
|
||||
const promptText = extractPromptText(input.body);
|
||||
const effort = extractEffort(input.body);
|
||||
const toolMaps = buildAppServerToolMaps(input.body);
|
||||
const hasTools = toolMaps.specs.length > 0;
|
||||
const events = new AsyncEventQueue<AdapterEvent>();
|
||||
const client = new CodexAppServerClient(this.clientOptions);
|
||||
|
||||
const run = async () => {
|
||||
let terminated = false;
|
||||
// Resolves when the turn reaches a terminal state (turn/completed, error,
|
||||
// or an item/tool/call passthrough). `turn/start` resolving only means the
|
||||
// turn was ACCEPTED (status: inProgress) — the model's output arrives later
|
||||
// as notifications. run() MUST await this before the finally-block closes
|
||||
// the client, otherwise the socket is torn down mid-turn and the event
|
||||
// queue never receives its terminal event (the request then hangs until the
|
||||
// caller's timeout). See translateNotification: it returns true on the
|
||||
// terminal notification, which is where we settle this.
|
||||
let settleTurn!: () => void;
|
||||
const turnDone = new Promise<void>((resolve) => {
|
||||
settleTurn = resolve;
|
||||
});
|
||||
const markTerminated = () => {
|
||||
if (terminated) return;
|
||||
terminated = true;
|
||||
settleTurn();
|
||||
};
|
||||
const finishTurn = () => {
|
||||
if (terminated) return;
|
||||
events.push({ type: "done", endTurn: true });
|
||||
events.close();
|
||||
markTerminated();
|
||||
};
|
||||
try {
|
||||
await client.connect(config.url, config.token);
|
||||
await client.request("initialize", {
|
||||
clientInfo: {
|
||||
name: "omniroute-codex-app-server",
|
||||
title: null,
|
||||
version: "1.0",
|
||||
},
|
||||
// Harness function tools are advertised via thread/start's `dynamicTools`,
|
||||
// which is an EXPERIMENTAL app-server field: opt into experimental API so
|
||||
// codex accepts it (and can emit the item/tool/call ServerRequest).
|
||||
capabilities: hasTools
|
||||
? { experimentalApi: true, requestAttestation: false }
|
||||
: null,
|
||||
});
|
||||
const threadResult = (await client.request("thread/start", {
|
||||
cwd: config.cwd,
|
||||
// OmniRoute is a router: the HARNESS that consumes OmniRoute owns tool
|
||||
// execution and policy. codex must therefore NEVER block a turn waiting
|
||||
// on its own interactive approval, and its own sandbox must not gate the
|
||||
// model — the harness decides what actually runs. So we pair
|
||||
// approvalPolicy:"never" (non-interactive; codex never prompts) with
|
||||
// sandbox:"danger-full-access" (codex's own sandbox imposes no
|
||||
// restriction), mirroring codexInstructions.ts:50 ("never +
|
||||
// danger-full-access = take advantage of it"). Any server→client
|
||||
// approval request that still arrives is auto-APPROVED by the client
|
||||
// (see CodexAppServerClient), never denied — denial would sabotage the
|
||||
// harness's tool calls. Callers can override both via providerSpecificData.
|
||||
approvalPolicy: config.approvalPolicy ?? "never",
|
||||
sandbox: config.sandbox ?? "danger-full-access",
|
||||
// INBOUND harness tools → codex. The client tells the app-server which
|
||||
// function tools are available for the thread via the `dynamicTools`
|
||||
// field on thread/start (a DynamicToolSpec[] under the experimental API,
|
||||
// verified from the real codex binary; see appServerEvents.ts). codex
|
||||
// then invokes them by sending the `item/tool/call` ServerRequest back
|
||||
// to the client (DynamicToolCallParams), which we PASS THROUGH.
|
||||
...(hasTools ? { dynamicTools: toolMaps.specs } : {}),
|
||||
})) as { thread?: { id?: unknown }; threadId?: unknown };
|
||||
// The live app-server (codex 0.149.0) returns the thread under
|
||||
// result.thread.id — NOT a top-level threadId (verified against the real
|
||||
// binary 2026-08-22). Keep the top-level fallback for forward/back compat.
|
||||
const threadId =
|
||||
threadResult && typeof threadResult.thread?.id === "string"
|
||||
? threadResult.thread.id
|
||||
: threadResult && typeof threadResult.threadId === "string"
|
||||
? threadResult.threadId
|
||||
: "";
|
||||
|
||||
client.onNotification((method, params) => {
|
||||
if (terminated) return;
|
||||
const isTerminal = translateNotification(method, params, (event) => events.push(event));
|
||||
if (isTerminal) {
|
||||
events.close();
|
||||
markTerminated();
|
||||
}
|
||||
});
|
||||
|
||||
// OUTBOUND codex tool call → harness. codex asks us to execute a harness
|
||||
// tool via the `item/tool/call` ServerRequest. OmniRoute is a STATELESS
|
||||
// ROUTER and CANNOT execute the harness's tool (the tool body lives in the
|
||||
// harness downstream). So we PASS IT THROUGH: emit tool_call_* AdapterEvents
|
||||
// (the bridge renders a Responses function_call / custom_tool_call /
|
||||
// tool_search_call), settle the app-server request with a benign
|
||||
// DynamicToolCallResponse so codex does not hang, and COMPLETE the turn.
|
||||
// The harness runs the tool and replays the result in a fresh /v1/responses
|
||||
// request (the stateless-full-history contract every OmniRoute provider uses).
|
||||
client.onToolCall((_id, params, api) => {
|
||||
if (terminated) return;
|
||||
const toolParams = (params && typeof params === "object" ? params : {}) as DynamicToolCallLike;
|
||||
translateToolCall(toolParams, (event) => events.push(event));
|
||||
// Settle the app-server request so the socket does not stall. The router
|
||||
// does not have the tool output (the harness will produce it next turn),
|
||||
// so we report the passthrough as an unsuccessful in-line result and end
|
||||
// the turn — the function_call has already been surfaced to the harness.
|
||||
api.respond({
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: "router: tool executed by harness; call surfaced as function_call",
|
||||
},
|
||||
],
|
||||
success: false,
|
||||
});
|
||||
finishTurn();
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
try {
|
||||
client.notify("turn/interrupt", { threadId, turnId: "" });
|
||||
} catch {
|
||||
/* interrupt best-effort */
|
||||
}
|
||||
// Unblock run() so the finally-block can tear down the client. Without
|
||||
// this, an aborted request would wait on turnDone until the terminal
|
||||
// notification that will never come.
|
||||
if (!terminated) {
|
||||
events.close();
|
||||
markTerminated();
|
||||
}
|
||||
};
|
||||
input.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
const turnInput: CodexTextUserInput[] = [
|
||||
{ type: "text", text: promptText, text_elements: [] },
|
||||
];
|
||||
await client.request("turn/start", {
|
||||
threadId,
|
||||
input: turnInput,
|
||||
model: input.model,
|
||||
...(effort ? { effort } : {}),
|
||||
});
|
||||
// `turn/start` resolving only ACCEPTS the turn (status: inProgress). The
|
||||
// model's output (agentMessage deltas) and the terminal turn/completed
|
||||
// arrive AFTER, as notifications. Wait for the terminal signal before
|
||||
// falling through to the finally-block — otherwise client.close() tears
|
||||
// down the socket mid-turn and the queue never closes (request hangs).
|
||||
await turnDone;
|
||||
} catch (err) {
|
||||
if (!terminated) {
|
||||
events.push({
|
||||
type: "error",
|
||||
message: sanitizeErrorMessage(err instanceof Error ? err.message : err),
|
||||
status: 502,
|
||||
errorType: "provider_error",
|
||||
code: "codex_app_server_turn_failed",
|
||||
});
|
||||
events.close();
|
||||
markTerminated();
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
};
|
||||
|
||||
if (!input.stream) {
|
||||
const running = run();
|
||||
const collected = await events.collect();
|
||||
await running;
|
||||
const response = buildResponseJSON(collected, input.model, {
|
||||
toolNsMap: toolMaps.namespace,
|
||||
freeformToolNames: toolMaps.freeform,
|
||||
toolSearchToolNames: toolMaps.toolSearch,
|
||||
});
|
||||
return {
|
||||
response: new Response(JSON.stringify(response), { status: 200, headers: JSON_HEADERS }),
|
||||
url: config.url,
|
||||
};
|
||||
}
|
||||
|
||||
void run();
|
||||
const stream = bridgeToResponsesSSE(
|
||||
events,
|
||||
input.model,
|
||||
toolMaps.namespace,
|
||||
toolMaps.freeform,
|
||||
toolMaps.toolSearch,
|
||||
() => client.close(),
|
||||
2_000
|
||||
);
|
||||
return {
|
||||
response: new Response(stream, { status: 200, headers: SSE_HEADERS }),
|
||||
url: config.url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function errorResponse(status: number, message: string, code: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code,
|
||||
message: sanitizeErrorMessage(message),
|
||||
type: status >= 500 ? "provider_error" : "invalid_request_error",
|
||||
},
|
||||
}),
|
||||
{ status, headers: JSON_HEADERS }
|
||||
);
|
||||
}
|
||||
|
||||
// re-export config type for consumers/tests
|
||||
export type { CodexAppServerConfig };
|
||||
@@ -58,6 +58,8 @@ import {
|
||||
type CodexEffortLevel as EffortLevel,
|
||||
} from "./codex/reasoningSuffix.ts";
|
||||
import { repairMissingCodexToolCallOutputs } from "./codex/toolCallRepair.ts";
|
||||
import { resolveAppServerConfig } from "./codex/appServerConfig.ts";
|
||||
import { CodexAppServerExecutor } from "./codex-app-server.ts";
|
||||
// Re-exported for external importers (tests + provider services).
|
||||
export { isCodexFreePlan, normalizeCodexTools } from "./codex/tools.ts";
|
||||
|
||||
@@ -102,6 +104,12 @@ export function __setCodexWebSocketTransportForTesting(
|
||||
_websocketOverride = websocket;
|
||||
}
|
||||
|
||||
// Exposed for the app-server transport, which needs the same wreq-js websocket
|
||||
// factory (with the testing override honored) to open its JSON-RPC socket.
|
||||
export function getCodexAppServerWebsocketTransport(): WebsocketFn | null {
|
||||
return getCodexWebSocketTransport();
|
||||
}
|
||||
|
||||
function codexWebSocketUnavailableResponse(): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -395,6 +403,34 @@ function isCodexWsGloballyEnabled(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global Codex app-server kill-switch (feature flag OMNIROUTE_CODEX_APP_SERVER_ENABLED,
|
||||
* default ON). Fail-open, mirroring isCodexWsGloballyEnabled.
|
||||
*/
|
||||
function isCodexAppServerGloballyEnabled(): boolean {
|
||||
try {
|
||||
return isFeatureFlagEnabled("OMNIROUTE_CODEX_APP_SERVER_ENABLED");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the connection opted into the app-server transport
|
||||
* (providerSpecificData.codexTransport === "app-server") AND the app-server is
|
||||
* configured (URL + token resolvable) AND the global flag is on. Selected BEFORE
|
||||
* the websocket check so it wins when configured.
|
||||
*/
|
||||
export function isCodexAppServerRequired(credentials: unknown): boolean {
|
||||
if (!isCodexAppServerGloballyEnabled()) return false;
|
||||
const providerSpecificData =
|
||||
credentials && typeof credentials === "object"
|
||||
? (credentials as { providerSpecificData?: Record<string, unknown> }).providerSpecificData
|
||||
: null;
|
||||
if (providerSpecificData?.codexTransport !== "app-server") return false;
|
||||
return !!resolveAppServerConfig(providerSpecificData);
|
||||
}
|
||||
|
||||
export function isCodexResponsesWebSocketRequired(_model: string, credentials: unknown): boolean {
|
||||
// Global kill-switch (default ON). When disabled, Codex never uses the WS
|
||||
// transport — even per-connection codexTransport=websocket falls back to the
|
||||
@@ -760,6 +796,8 @@ function normalizeCodexWsHeaders(headers: Record<string, string>): Record<string
|
||||
* IMPORTANT: Includes chatgpt-account-id header for workspace binding.
|
||||
*/
|
||||
export class CodexExecutor extends BaseExecutor {
|
||||
private appServer: CodexAppServerExecutor | null = null;
|
||||
|
||||
constructor() {
|
||||
super("codex", PROVIDERS.codex);
|
||||
}
|
||||
@@ -778,6 +816,15 @@ export class CodexExecutor extends BaseExecutor {
|
||||
);
|
||||
const nextInput = { ...requestInput, credentials };
|
||||
|
||||
if (isCodexAppServerRequired(nextInput.credentials)) {
|
||||
if (!this.appServer) {
|
||||
this.appServer = new CodexAppServerExecutor({
|
||||
websocketFn: getCodexAppServerWebsocketTransport(),
|
||||
});
|
||||
}
|
||||
return this.appServer.execute(nextInput);
|
||||
}
|
||||
|
||||
if (!isCodexResponsesWebSocketRequired(nextInput.model, nextInput.credentials)) {
|
||||
const httpResult = await super.execute(nextInput);
|
||||
if (codexDropNonstandardEvents()) {
|
||||
|
||||
102
open-sse/executors/codex/appServerAuthProbe.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Layer-2 auth-status probe for the Codex app-server transport.
|
||||
*
|
||||
* The HTTP `/readyz` endpoint only proves the app-server PROCESS is up — not that
|
||||
* its Codex CLI is signed in. A public user whose CLI is not yet authenticated
|
||||
* would otherwise see a green "ready" badge and then fail on the first turn with
|
||||
* an upstream auth error. This probe opens the same JSON-RPC/WebSocket the
|
||||
* executor uses and calls `account/read` (verified against codex 0.149.0): an
|
||||
* authenticated server returns `{ account: { type, email, planType }, ... }`;
|
||||
* a logged-out server returns no account (or an error). So the presence of
|
||||
* `result.account` is the "authenticated" signal.
|
||||
*
|
||||
* Kept separate from the executor turn path so the health check pulls in only the
|
||||
* lightweight client + transport, and so it is independently unit-testable with a
|
||||
* fake websocketFn.
|
||||
*/
|
||||
import {
|
||||
CodexAppServerClient,
|
||||
type CodexAppServerWebsocketFn,
|
||||
} from "./appServerClient.ts";
|
||||
import type { CodexAppServerConfig } from "./appServerConfig.ts";
|
||||
|
||||
export type CodexAppServerAuthStatus =
|
||||
| { state: "authenticated"; account: { type?: string; email?: string; planType?: string } }
|
||||
| { state: "logged_out"; reason: string }
|
||||
| { state: "unknown"; reason: string };
|
||||
|
||||
interface AccountReadResult {
|
||||
account?: { type?: unknown; email?: unknown; planType?: unknown } | null;
|
||||
requiresOpenaiAuth?: unknown;
|
||||
}
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a short-lived WS to the app-server, initialize, and read the account.
|
||||
* Returns an auth status; never throws (maps failures to state "unknown").
|
||||
*
|
||||
* @param config resolved app-server config (url + capability token).
|
||||
* @param websocketFn the wreq-js websocket factory
|
||||
* (getCodexAppServerWebsocketTransport()); when null, returns "unknown".
|
||||
* @param timeoutMs overall budget for connect + account/read.
|
||||
*/
|
||||
export async function probeCodexAppServerAuth(
|
||||
config: CodexAppServerConfig,
|
||||
websocketFn: CodexAppServerWebsocketFn | null,
|
||||
timeoutMs = 8000
|
||||
): Promise<CodexAppServerAuthStatus> {
|
||||
if (!websocketFn) {
|
||||
return { state: "unknown", reason: "websocket transport unavailable" };
|
||||
}
|
||||
const client = new CodexAppServerClient({ websocketFn, defaultTimeoutMs: timeoutMs });
|
||||
const deadline = new Promise<CodexAppServerAuthStatus>((resolve) =>
|
||||
setTimeout(() => resolve({ state: "unknown", reason: "auth probe timed out" }), timeoutMs)
|
||||
);
|
||||
|
||||
const run = (async (): Promise<CodexAppServerAuthStatus> => {
|
||||
try {
|
||||
await client.connect(config.url, config.token);
|
||||
await client.request(
|
||||
"initialize",
|
||||
{
|
||||
clientInfo: { name: "omniroute-codex-app-server-health", title: null, version: "1.0" },
|
||||
capabilities: null,
|
||||
},
|
||||
timeoutMs
|
||||
);
|
||||
// account/read: authenticated → { account: {...} }; logged out → no account.
|
||||
const result = (await client.request("account/read", {}, timeoutMs)) as AccountReadResult;
|
||||
const account = result?.account;
|
||||
if (account && typeof account === "object") {
|
||||
return {
|
||||
state: "authenticated",
|
||||
account: {
|
||||
type: str(account.type),
|
||||
email: str(account.email),
|
||||
planType: str(account.planType),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: "logged_out",
|
||||
reason: "app-server reachable but its Codex CLI is not signed in",
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// A JSON-RPC error on account/read (e.g. AuthRequiredError) also means
|
||||
// "up but not authenticated" — surface it as logged_out, not unknown, so
|
||||
// the dashboard offers "Sign in with ChatGPT" rather than a scary error.
|
||||
if (/auth|login|sign|unauthor|401/i.test(message)) {
|
||||
return { state: "logged_out", reason: message };
|
||||
}
|
||||
return { state: "unknown", reason: message };
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
})();
|
||||
|
||||
return Promise.race([run, deadline]);
|
||||
}
|
||||
289
open-sse/executors/codex/appServerClient.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Id-correlated JSON-RPC 2.0 client over a single WebSocket, for the Codex
|
||||
* app-server transport.
|
||||
*
|
||||
* Ported from the stdio JSON-RPC pattern in `devin-cli-agentic.ts` (monotonic id,
|
||||
* pending-request map settled on responses, notification vs response
|
||||
* discrimination, settle-once) onto the wreq-js WebSocket transport used by the
|
||||
* existing Codex WS path.
|
||||
*
|
||||
* The critical addition over the other transports is a catch-all handler for
|
||||
* server -> client ServerRequests: the app-server can ask the client to approve a
|
||||
* command / patch / permission. OmniRoute is a ROUTER — the harness that consumes
|
||||
* it owns tool execution and policy — so codex must never stall a turn on its own
|
||||
* interactive approval. Every inbound ServerRequest is always answered: approval
|
||||
* prompts are auto-APPROVED (so the model's agentic tool calls proceed; the harness
|
||||
* decides what really runs), and anything else we can't service gets a JSON-RPC
|
||||
* error so the id is always settled and the turn never hangs.
|
||||
*/
|
||||
|
||||
// wreq-js WebSocket surface (mirrors the private type in codex.ts:71-77).
|
||||
export type CodexWreqWebSocket = {
|
||||
send: (data: string) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
onmessage: ((event: { data: unknown }) => void) | null;
|
||||
onerror: ((event: { message?: string }) => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
};
|
||||
|
||||
export type CodexAppServerWebsocketFn = (
|
||||
url: string,
|
||||
opts?: Record<string, unknown>
|
||||
) => Promise<CodexWreqWebSocket>;
|
||||
|
||||
interface PendingReq {
|
||||
resolve: (result: unknown) => void;
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
// The set of ServerRequest methods that are approval prompts (see PROTOCOL-DIGEST
|
||||
// "Server -> client REQUESTS"). All of these get an auto-denial decision.
|
||||
const APPROVAL_REQUEST_METHODS = new Set<string>([
|
||||
"item/commandExecution/requestApproval",
|
||||
"item/fileChange/requestApproval",
|
||||
"item/permissions/requestApproval",
|
||||
"applyPatchApproval",
|
||||
"execCommandApproval",
|
||||
]);
|
||||
|
||||
const ROUTER_APPROVAL_NOTE = "router: harness-controlled execution";
|
||||
|
||||
export interface CodexAppServerClientOptions {
|
||||
/** Transport factory. Defaults to the shared wreq-js websocket() when omitted. */
|
||||
websocketFn?: CodexAppServerWebsocketFn | null;
|
||||
/** Default per-request timeout (ms). */
|
||||
defaultTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The app-server → client REQUEST method by which codex invokes a harness-defined
|
||||
* (dynamic) function tool. See appServerEvents.ts:CODEX_APPSERVER_TOOL_CALL_METHOD.
|
||||
* A stateless router cannot execute the harness's tool, so this is handled by a
|
||||
* PASSTHROUGH handler (surface it as a Responses function_call and complete the
|
||||
* turn) rather than by the default -32601 rejection.
|
||||
*/
|
||||
const TOOL_CALL_REQUEST_METHOD = "item/tool/call";
|
||||
|
||||
/**
|
||||
* Handler for a server → client `item/tool/call` ServerRequest. It receives the
|
||||
* JSON-RPC id and raw params (DynamicToolCallParams). It OWNS settling the id
|
||||
* (call `respond`/`respondError`) so the socket never hangs. Returning lets the
|
||||
* executor emit tool_call_* AdapterEvents + complete the turn.
|
||||
*/
|
||||
export type CodexAppServerToolCallHandler = (
|
||||
id: number,
|
||||
params: unknown,
|
||||
api: {
|
||||
/** Settle the request id with a JSON-RPC result (a DynamicToolCallResponse). */
|
||||
respond: (result: unknown) => void;
|
||||
/** Settle the request id with a JSON-RPC error. */
|
||||
respondError: (code: number, message: string) => void;
|
||||
}
|
||||
) => void;
|
||||
|
||||
export class CodexAppServerClient {
|
||||
private ws: CodexWreqWebSocket | null = null;
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<number, PendingReq>();
|
||||
private notificationHandler: (method: string, params: unknown) => void = () => {};
|
||||
private toolCallHandler: CodexAppServerToolCallHandler | null = null;
|
||||
private readonly websocketFn: CodexAppServerWebsocketFn | null;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private closed = false;
|
||||
|
||||
constructor(options: CodexAppServerClientOptions = {}) {
|
||||
this.websocketFn = options.websocketFn ?? null;
|
||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the WebSocket and attach the capability token as `Authorization: Bearer`.
|
||||
* Do NOT add any chatgpt.com Origin/WS header normalization here — the local
|
||||
* app-server wants only the Authorization header.
|
||||
*/
|
||||
async connect(url: string, token: string): Promise<void> {
|
||||
if (!this.websocketFn) {
|
||||
throw new Error("Codex app-server websocket transport unavailable");
|
||||
}
|
||||
// wreq-js's websocket() REQUIRES a browser/os impersonation profile alongside
|
||||
// headers — the same shape the existing Codex WS path uses (codex.ts:980).
|
||||
// Omitting browser/os makes the native call hang/throw, so the app-server
|
||||
// turn never connects. The local app-server ignores the impersonation
|
||||
// fingerprint; only the Authorization bearer matters for its ws-auth.
|
||||
this.ws = await this.websocketFn(url, {
|
||||
browser: "chrome_142",
|
||||
os: "windows",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
this.ws.onmessage = (event) => this.onFrame(event.data);
|
||||
this.ws.onerror = (event) => this.failAll(event?.message ?? "app-server socket error");
|
||||
this.ws.onclose = () => this.failAll("app-server connection closed");
|
||||
}
|
||||
|
||||
/** Send a ClientRequest and resolve when its id-matched response arrives. */
|
||||
request<T = unknown>(method: string, params: unknown, timeoutMs = this.defaultTimeoutMs): Promise<T> {
|
||||
const id = this.nextId++;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
if (!this.ws || this.closed) {
|
||||
reject(new Error(`Cannot send ${method}: app-server connection is not open`));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Codex app-server request "${method}" timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, {
|
||||
resolve: (result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result as T);
|
||||
},
|
||||
reject: (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
this.ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a ClientNotification (no id, no reply expected — e.g. turn/interrupt). */
|
||||
notify(method: string, params: unknown): void {
|
||||
if (!this.ws || this.closed) return;
|
||||
this.ws.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
|
||||
}
|
||||
|
||||
/** Register the handler that receives server -> client NOTIFICATIONS (no id). */
|
||||
onNotification(fn: (method: string, params: unknown) => void): void {
|
||||
this.notificationHandler = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the handler for the `item/tool/call` server → client ServerRequest
|
||||
* (a harness function-tool invocation). When set, `item/tool/call` is routed to
|
||||
* this handler INSTEAD of the default -32601 rejection; the handler must settle
|
||||
* the id via the provided `respond`/`respondError`. When unset, `item/tool/call`
|
||||
* falls through to the default rejection (keeps the turn unstuck).
|
||||
*/
|
||||
onToolCall(fn: CodexAppServerToolCallHandler): void {
|
||||
this.toolCallHandler = fn;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
try {
|
||||
this.ws?.close(1000, "done");
|
||||
} catch {
|
||||
/* socket close race — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse one inbound frame and dispatch by JSON-RPC shape. */
|
||||
private onFrame(raw: unknown): void {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
const line = typeof raw === "string" ? raw : Buffer.from(raw as Uint8Array).toString("utf8");
|
||||
msg = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
// A non-JSON frame is unusable; drop it rather than crash the socket.
|
||||
return;
|
||||
}
|
||||
|
||||
const hasId = msg.id !== undefined && msg.id !== null;
|
||||
const hasMethod = typeof msg.method === "string";
|
||||
|
||||
if (hasId && !hasMethod) {
|
||||
// A RESPONSE to one of our ClientRequests → settle the pending map.
|
||||
const id = msg.id as number;
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(id);
|
||||
if (msg.error) {
|
||||
const err = msg.error as { code?: unknown; message?: unknown };
|
||||
pending.reject(new Error(`${String(err.code ?? "error")}: ${String(err.message ?? "unknown")}`));
|
||||
} else {
|
||||
pending.resolve(msg.result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMethod && hasId) {
|
||||
// A server -> client REQUEST → we MUST reply with the matching id or the turn stalls.
|
||||
const id = msg.id as number;
|
||||
const method = msg.method as string;
|
||||
// A harness function-tool invocation is routed to the passthrough handler
|
||||
// (if registered) so the executor can surface it as a Responses function_call
|
||||
// and complete the turn. The handler owns settling the id.
|
||||
if (method === TOOL_CALL_REQUEST_METHOD && this.toolCallHandler) {
|
||||
this.toolCallHandler(id, msg.params, {
|
||||
respond: (result) => this.respondToRequest(id, result),
|
||||
respondError: (code, message) => this.respondErrorToRequest(id, code, message),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.answerServerRequest(id, method);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasMethod) {
|
||||
// A server -> client NOTIFICATION → hand to the stream.
|
||||
this.notificationHandler(msg.method as string, msg.params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Always answer an inbound ServerRequest so its id is settled. Approval prompts
|
||||
* are auto-APPROVED (OmniRoute is a router; the harness that consumes it owns
|
||||
* execution policy, so codex's own approval must not block the turn). Anything
|
||||
* we cannot service gets a JSON-RPC error so the id is still settled.
|
||||
*/
|
||||
private answerServerRequest(id: number, method: string): void {
|
||||
if (!this.ws || this.closed) return;
|
||||
if (APPROVAL_REQUEST_METHODS.has(method)) {
|
||||
// ReviewDecision "approved" — let the model's agentic action proceed. The
|
||||
// harness downstream of OmniRoute is the real gate. Note the note field is
|
||||
// advisory; the decision string is what codex acts on.
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
result: { decision: "approved", note: ROUTER_APPROVAL_NOTE },
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Non-approval server request we do not service here: reject the id so the
|
||||
// app-server does not wait on us (belt-and-suspenders; keeps turns unstuck).
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `router: unsupported server request "${method}"`,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Settle an inbound ServerRequest id with a JSON-RPC result. */
|
||||
private respondToRequest(id: number, result: unknown): void {
|
||||
if (!this.ws || this.closed) return;
|
||||
this.ws.send(JSON.stringify({ jsonrpc: "2.0", id, result }));
|
||||
}
|
||||
|
||||
/** Settle an inbound ServerRequest id with a JSON-RPC error. */
|
||||
private respondErrorToRequest(id: number, code: number, message: string): void {
|
||||
if (!this.ws || this.closed) return;
|
||||
this.ws.send(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }));
|
||||
}
|
||||
|
||||
private failAll(reason: string): void {
|
||||
const err = new Error(reason);
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
pending.reject(err);
|
||||
}
|
||||
this.notificationHandler("__transport_closed__", { reason });
|
||||
}
|
||||
}
|
||||
94
open-sse/executors/codex/appServerConfig.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* Resolved connection config for the Codex app-server WS transport.
|
||||
*
|
||||
* The app-server is a locally-running `codex app-server` process reachable over a
|
||||
* single WebSocket speaking JSON-RPC 2.0. It self-manages OpenAI auth + model
|
||||
* routing; the ONLY credential OmniRoute presents is the capability token, sent as
|
||||
* `Authorization: Bearer <hex>` on the WS handshake.
|
||||
*/
|
||||
export interface CodexAppServerConfig {
|
||||
/** ws:// or wss:// URL of the app-server (e.g. "ws://ts-egress:1456"). */
|
||||
url: string;
|
||||
/** Capability token (hex string) sent as `Authorization: Bearer <token>`. */
|
||||
token: string;
|
||||
/** Working directory passed to `thread/start { cwd }` inside the codex container. */
|
||||
cwd: string;
|
||||
/**
|
||||
* Optional codex approval policy override (AskForApproval). Defaults to "never"
|
||||
* in the executor so codex runs non-interactively and never blocks the turn on
|
||||
* its own approval — the harness that consumes OmniRoute owns execution policy.
|
||||
*/
|
||||
approvalPolicy?: string;
|
||||
/**
|
||||
* Optional codex sandbox override (SandboxMode). Defaults to "danger-full-access"
|
||||
* in the executor so codex's own sandbox does not gate the model; the harness is
|
||||
* the real gate. Callers may tighten this per request via providerSpecificData.
|
||||
*/
|
||||
sandbox?: string;
|
||||
}
|
||||
|
||||
type ProviderSpecificData = Record<string, unknown> | null | undefined;
|
||||
|
||||
function firstString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the capability token, preferring an inline token, then a token FILE path.
|
||||
* The token file (produced by `codex app-server --ws-token-file <path>`) holds the
|
||||
* same hex string that is presented as the bearer token.
|
||||
*/
|
||||
function resolveToken(psd: ProviderSpecificData): string | null {
|
||||
const inline = firstString(
|
||||
psd?.codexAppServerToken,
|
||||
process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN
|
||||
);
|
||||
if (inline) return inline;
|
||||
|
||||
const tokenFile = firstString(
|
||||
psd?.codexAppServerTokenFile,
|
||||
process.env.OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE
|
||||
);
|
||||
if (!tokenFile) return null;
|
||||
try {
|
||||
const contents = readFileSync(tokenFile, "utf8").trim();
|
||||
return contents.length > 0 ? contents : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isWebSocketUrl(url: string): boolean {
|
||||
return url.startsWith("ws://") || url.startsWith("wss://");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the app-server connection config from providerSpecificData with env
|
||||
* fallbacks. Returns `null` when not fully configured (URL + token both required)
|
||||
* so the gating predicate `isCodexAppServerRequired` stays false and Codex falls
|
||||
* back to its other transports.
|
||||
*/
|
||||
export function resolveAppServerConfig(psd: ProviderSpecificData): CodexAppServerConfig | null {
|
||||
const url = firstString(psd?.codexAppServerUrl, process.env.OMNIROUTE_CODEX_APPSERVER_WS);
|
||||
if (!url || !isWebSocketUrl(url)) return null;
|
||||
|
||||
const token = resolveToken(psd);
|
||||
if (!token) return null;
|
||||
|
||||
const cwd =
|
||||
firstString(psd?.codexAppServerCwd, process.env.OMNIROUTE_CODEX_APPSERVER_CWD) ?? "/tmp";
|
||||
|
||||
const approvalPolicy =
|
||||
firstString(psd?.codexAppServerApprovalPolicy, process.env.OMNIROUTE_CODEX_APPSERVER_APPROVAL) ??
|
||||
undefined;
|
||||
const sandbox =
|
||||
firstString(psd?.codexAppServerSandbox, process.env.OMNIROUTE_CODEX_APPSERVER_SANDBOX) ??
|
||||
undefined;
|
||||
|
||||
return { url, token, cwd, ...(approvalPolicy ? { approvalPolicy } : {}), ...(sandbox ? { sandbox } : {}) };
|
||||
}
|
||||
208
open-sse/executors/codex/appServerEvents.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { AdapterEvent, CodexUsage } from "../../vendor/codex-chatgpt-web/types.ts";
|
||||
|
||||
/**
|
||||
* Map Codex app-server JSON-RPC notifications onto the AdapterEvent stream that
|
||||
* `bridgeToResponsesSSE` / `buildResponseJSON` consume.
|
||||
*
|
||||
* Wire method names are the slash-notation ServerNotification variants verified
|
||||
* from the real codex binary (see PROTOCOL-DIGEST.md). Only the handful needed for
|
||||
* a plain text turn are mapped; everything else is ignored.
|
||||
*
|
||||
* The `*Notification` param TYPES referenced below (adapted from the ts-rs bindings):
|
||||
* AgentMessageDeltaNotification { threadId, turnId, itemId, delta }
|
||||
* ReasoningTextDeltaNotification { threadId, turnId, itemId, delta, contentIndex }
|
||||
* TurnCompletedNotification { threadId, turn } (turn carries usage)
|
||||
* ErrorNotification { error, willRetry, threadId, turnId }
|
||||
*/
|
||||
|
||||
// Wire method names (slash-notation) → intent. Kept as named constants so a typo
|
||||
// can't silently break the mapping.
|
||||
export const CODEX_APPSERVER_METHODS = {
|
||||
agentMessageDelta: "item/agentMessage/delta",
|
||||
reasoningTextDelta: "item/reasoning/textDelta",
|
||||
reasoningSummaryTextDelta: "item/reasoning/summaryTextDelta",
|
||||
turnCompleted: "turn/completed",
|
||||
error: "error",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The app-server → client REQUEST method by which codex invokes a harness-defined
|
||||
* (dynamic) function tool. It is NOT a notification: it is a server→client
|
||||
* ServerRequest that BLOCKS the codex turn waiting for a `DynamicToolCallResponse`
|
||||
* with the tool's output.
|
||||
*
|
||||
* `params` shape = `DynamicToolCallParams` (ts-rs binding):
|
||||
* { threadId, turnId, callId, namespace: string | null, tool: string, arguments: JsonValue }
|
||||
*
|
||||
* OmniRoute is a STATELESS ROUTER: it cannot execute the harness's tool (the tool
|
||||
* body lives in the harness downstream, not here). So instead of "executing" the
|
||||
* call, we PASS IT THROUGH: emit tool_call_* AdapterEvents so the bridge renders a
|
||||
* Responses `function_call` output item, then complete the turn. The harness runs
|
||||
* the tool and replays the result in a fresh /v1/responses request (the same
|
||||
* stateless-full-history contract every other OmniRoute provider uses).
|
||||
*/
|
||||
export const CODEX_APPSERVER_TOOL_CALL_METHOD = "item/tool/call";
|
||||
|
||||
/** Minimal shape of the DynamicToolCallParams we consume for the passthrough. */
|
||||
export interface DynamicToolCallLike {
|
||||
callId?: unknown;
|
||||
namespace?: unknown;
|
||||
tool?: unknown;
|
||||
arguments?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire name the bridge's `toolNsMap` is keyed by: namespaced (MCP) tools are
|
||||
* flattened to "<namespace>__<name>". codex sends the namespace + tool separately
|
||||
* on DynamicToolCallParams, so we reconstruct the flat name for the round-trip.
|
||||
*/
|
||||
export function dynamicToolWireName(namespace: unknown, tool: unknown): string {
|
||||
const name = typeof tool === "string" ? tool : "";
|
||||
return typeof namespace === "string" && namespace.length > 0
|
||||
? `${namespace}__${name}`
|
||||
: name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate ONE codex `item/tool/call` ServerRequest into the tool_call_* AdapterEvent
|
||||
* triple the bridge already knows how to turn into a Responses function_call /
|
||||
* custom_tool_call / tool_search_call (see bridge.ts:700-784). The `arguments` are
|
||||
* serialized to a JSON string (the bridge accumulates `tool_call_delta.arguments`
|
||||
* as a string and JSON.parses it at close).
|
||||
*
|
||||
* This emits the COMPLETE call in one shot (start → delta → end) because the
|
||||
* server-request carries the fully-formed arguments (codex does not stream dynamic
|
||||
* tool-call arguments to the client the way the chatgpt-web adapter streams native
|
||||
* ones). The caller is responsible for then completing the turn.
|
||||
*/
|
||||
export function translateToolCall(
|
||||
params: DynamicToolCallLike,
|
||||
push: (event: AdapterEvent) => void
|
||||
): void {
|
||||
const callId =
|
||||
typeof params.callId === "string" && params.callId.length > 0
|
||||
? params.callId
|
||||
: `call_${Math.random().toString(36).slice(2)}`;
|
||||
const name = dynamicToolWireName(params.namespace, params.tool);
|
||||
let argsStr = "{}";
|
||||
const rawArgs = params.arguments;
|
||||
if (typeof rawArgs === "string") {
|
||||
argsStr = rawArgs.length > 0 ? rawArgs : "{}";
|
||||
} else if (rawArgs !== undefined && rawArgs !== null) {
|
||||
try {
|
||||
argsStr = JSON.stringify(rawArgs);
|
||||
} catch {
|
||||
argsStr = "{}";
|
||||
}
|
||||
}
|
||||
push({ type: "tool_call_start", id: callId, name });
|
||||
if (argsStr.length > 0) push({ type: "tool_call_delta", arguments: argsStr });
|
||||
push({ type: "tool_call_end" });
|
||||
}
|
||||
|
||||
interface RawUsage {
|
||||
input_tokens?: number;
|
||||
cached_input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
reasoning_output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
/** Extract a numeric field defensively (the wire may omit or null it). */
|
||||
function num(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the app-server usage shape (snake_case token counts) into the canonical
|
||||
* CodexUsage the bridge expects. Returns undefined when nothing usable is present.
|
||||
*/
|
||||
export function mapUsage(raw: unknown): CodexUsage | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const u = raw as RawUsage;
|
||||
const inputTokens = num(u.input_tokens) ?? 0;
|
||||
const outputTokens = num(u.output_tokens) ?? 0;
|
||||
const usage: CodexUsage = { inputTokens, outputTokens };
|
||||
const cached = num(u.cached_input_tokens);
|
||||
if (cached !== undefined) {
|
||||
usage.cachedInputTokens = cached;
|
||||
usage.cacheReadInputTokens = cached;
|
||||
}
|
||||
const reasoning = num(u.reasoning_output_tokens);
|
||||
if (reasoning !== undefined) usage.reasoningOutputTokens = reasoning;
|
||||
const total = num(u.total_tokens);
|
||||
if (total !== undefined) usage.totalTokens = total;
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a usage object out of a `turn/completed` param. The Turn payload carries
|
||||
* token counts; different app-server builds nest it under `usage` or `tokenUsage`,
|
||||
* so probe both before giving up.
|
||||
*/
|
||||
function extractTurnUsage(params: Record<string, unknown>): CodexUsage | undefined {
|
||||
const turn = params.turn;
|
||||
if (turn && typeof turn === "object") {
|
||||
const t = turn as Record<string, unknown>;
|
||||
return mapUsage(t.usage) ?? mapUsage(t.tokenUsage) ?? mapUsage(t.token_usage);
|
||||
}
|
||||
return mapUsage(params.usage);
|
||||
}
|
||||
|
||||
function errorMessage(params: Record<string, unknown>): string {
|
||||
const err = params.error;
|
||||
if (err && typeof err === "object") {
|
||||
const m = (err as Record<string, unknown>).message;
|
||||
if (typeof m === "string" && m.length > 0) return m;
|
||||
}
|
||||
if (typeof params.message === "string" && params.message.length > 0) return params.message;
|
||||
return "Codex app-server reported an error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one notification into AdapterEvent(s) and push them into the queue.
|
||||
*
|
||||
* Returns `true` when the notification is terminal (turn/completed or error), so
|
||||
* the caller can close the event queue after draining.
|
||||
*/
|
||||
export function translateNotification(
|
||||
method: string,
|
||||
params: unknown,
|
||||
push: (event: AdapterEvent) => void
|
||||
): boolean {
|
||||
const p = (params && typeof params === "object" ? params : {}) as Record<string, unknown>;
|
||||
|
||||
switch (method) {
|
||||
case CODEX_APPSERVER_METHODS.agentMessageDelta: {
|
||||
const delta = p.delta;
|
||||
if (typeof delta === "string" && delta.length > 0) {
|
||||
push({ type: "text_delta", text: delta });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case CODEX_APPSERVER_METHODS.reasoningTextDelta:
|
||||
case CODEX_APPSERVER_METHODS.reasoningSummaryTextDelta: {
|
||||
const delta = p.delta;
|
||||
if (typeof delta === "string" && delta.length > 0) {
|
||||
push({ type: "thinking_delta", thinking: delta });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
case CODEX_APPSERVER_METHODS.turnCompleted: {
|
||||
push({ type: "done", usage: extractTurnUsage(p), endTurn: true });
|
||||
return true;
|
||||
}
|
||||
case CODEX_APPSERVER_METHODS.error: {
|
||||
push({
|
||||
type: "error",
|
||||
message: errorMessage(p),
|
||||
status: 502,
|
||||
errorType: "provider_error",
|
||||
code: "codex_app_server_turn_failed",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { GheCopilotExecutor } from "./ghe-copilot.ts";
|
||||
import { QoderExecutor } from "./qoder.ts";
|
||||
import { KiroExecutor } from "./kiro.ts";
|
||||
import { CodexExecutor } from "./codex.ts";
|
||||
import { CodexAppServerExecutor } from "./codex-app-server.ts";
|
||||
import { CursorExecutor } from "./cursor.ts";
|
||||
import { TraeExecutor } from "./trae.ts";
|
||||
import { DefaultExecutor } from "./default.ts";
|
||||
@@ -97,6 +98,7 @@ const executors = {
|
||||
"amazon-q": new KiroExecutor("amazon-q"),
|
||||
bedrock: new BedrockExecutor(),
|
||||
codex: new CodexExecutor(),
|
||||
"codex-app-server": new CodexAppServerExecutor({}, "codex-app-server"),
|
||||
"chatgpt-web-codex": new ChatGptWebCodexExecutor(),
|
||||
"cgpt-codex": new ChatGptWebCodexExecutor(),
|
||||
cursor: new CursorExecutor(),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
|
||||
import {
|
||||
BaseExecutor,
|
||||
type ExecuteInput,
|
||||
type ExecutorExecuteResult,
|
||||
type ProviderCredentials,
|
||||
} from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
|
||||
import {
|
||||
@@ -145,6 +150,101 @@ export function resolveOpencodeTargetFormat(provider: string, model: string): st
|
||||
return getModelTargetFormat(alias, model) || "openai";
|
||||
}
|
||||
|
||||
/**
|
||||
* muse-spark (opencode-go) burns its entire output budget on invisible
|
||||
* server-side reasoning before emitting any content. With small caller-set
|
||||
* budgets the upstream answers HTTP 200 with an empty message
|
||||
* (`{"message":{"role":"assistant"},"finish_reason":null}` and
|
||||
* `completion_tokens == max_tokens`) — chatCore then flags the fake success as
|
||||
* "Provider returned empty content" / 502 and burns a fallback attempt.
|
||||
*
|
||||
* Verified live 2026-08-23: max_tokens=64/100 → empty content;
|
||||
* 256/512/1024 → content present (hidden reasoning consumed 196–253 of it).
|
||||
*
|
||||
* Floor raised budgets only — explicit large budgets and non-muse-spark models
|
||||
* are untouched, and no budget is synthesized when the caller set none.
|
||||
*/
|
||||
export const MUSE_SPARK_MIN_OUTPUT_TOKENS = 512;
|
||||
|
||||
export function applyMuseSparkMinOutputTokens(model: string, body: Record<string, unknown>): void {
|
||||
if (!model.startsWith("muse-spark")) return;
|
||||
const current = body.max_tokens;
|
||||
if (typeof current !== "number" || !Number.isFinite(current)) return;
|
||||
if (current >= MUSE_SPARK_MIN_OUTPUT_TOKENS) return;
|
||||
body.max_tokens = MUSE_SPARK_MIN_OUTPUT_TOKENS;
|
||||
}
|
||||
|
||||
/**
|
||||
* muse-spark's gateway reports `finish_reason:"length"` whenever its hidden
|
||||
* reasoning consumed part of the output budget — even when the visible
|
||||
* completion is tiny relative to the requested budget (observed: ~270
|
||||
* completion tokens on a 128000-token request). OpenAI-protocol clients map a
|
||||
* "length" stop onto the caller's own max-tokens cap, so Claude Code aborts a
|
||||
* fully-delivered answer with "response exceeded the 128000 output token
|
||||
* maximum".
|
||||
*
|
||||
* Rewrite `length` → `stop` when the reported completion count proves the real
|
||||
* token limit was never reached (<90% of the caller's budget). Genuine
|
||||
* truncations at the budget are preserved. Streaming frames carry usage before
|
||||
* the terminal finish frame, so the completion count is known in time.
|
||||
*/
|
||||
export function normalizeMuseSparkFinishReason(
|
||||
payload: Record<string, unknown>,
|
||||
requestedBudget: number | null,
|
||||
/** Streaming: usage arrives in an earlier frame than the finish frame — caller passes the tracked count here. */
|
||||
completionOverride?: number | null
|
||||
): void {
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
for (const choice of choices) {
|
||||
if (!choice || typeof choice !== "object") continue;
|
||||
const record = choice as Record<string, unknown>;
|
||||
if (record.finish_reason !== "length") continue;
|
||||
if (requestedBudget === null || requestedBudget === undefined) continue;
|
||||
const usage = payload.usage as Record<string, unknown> | undefined;
|
||||
const completion =
|
||||
typeof completionOverride === "number"
|
||||
? completionOverride
|
||||
: typeof usage?.completion_tokens === "number"
|
||||
? usage.completion_tokens
|
||||
: null;
|
||||
if (completion === null) continue;
|
||||
if (completion < Math.floor(requestedBudget * 0.9)) {
|
||||
record.finish_reason = "stop";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** SSE line normalizer for muse-spark streams: tracks usage, rewrites finish frames. */
|
||||
export function createMuseSparkStreamFinishNormalizer(
|
||||
requestedBudget: number | null
|
||||
): (dataLine: string) => string {
|
||||
let completionTokens: number | null = null;
|
||||
return (line: string): string => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:") || trimmed.includes("[DONE]")) return line;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed.slice(5).trim());
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return line;
|
||||
const payload = parsed as Record<string, unknown>;
|
||||
const usage = payload.usage as Record<string, unknown> | undefined;
|
||||
if (usage && typeof usage.completion_tokens === "number") {
|
||||
completionTokens = usage.completion_tokens;
|
||||
}
|
||||
const hadFinish = Array.isArray(payload.choices)
|
||||
? (payload.choices as Array<Record<string, unknown>>).some(
|
||||
(c) => c && c.finish_reason === "length"
|
||||
)
|
||||
: false;
|
||||
if (!hadFinish) return line;
|
||||
normalizeMuseSparkFinishReason(payload, requestedBudget, completionTokens);
|
||||
return `data: ${JSON.stringify(payload)}`;
|
||||
};
|
||||
}
|
||||
|
||||
export class OpencodeExecutor extends BaseExecutor {
|
||||
/** Delegates to `isPremiumOpencodeModel`. Exported for testability. */
|
||||
static isPremiumModel(model: string, provider: string): boolean {
|
||||
@@ -224,6 +324,97 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
markAccountSuccess(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite muse-spark's bogus `finish_reason:"length"` (see the
|
||||
* normalizeMuseSparkFinishReason note) to `"stop"` on both streaming and
|
||||
* non-streaming success responses. Non-muse-spark models pass through
|
||||
* untouched.
|
||||
*/
|
||||
private normalizeMuseSparkResponse(
|
||||
input: ExecuteInput,
|
||||
result: ExecutorExecuteResult
|
||||
): ExecutorExecuteResult {
|
||||
const model = String(input.model ?? "");
|
||||
if (!model.startsWith("muse-spark")) return result;
|
||||
if (!("response" in result) || !result.response?.ok || !result.response.body) return result;
|
||||
const bodyObj =
|
||||
input.body && typeof input.body === "object" && !Array.isArray(input.body)
|
||||
? (input.body as Record<string, unknown>)
|
||||
: null;
|
||||
const rawBudget = bodyObj?.max_tokens;
|
||||
const budget = typeof rawBudget === "number" && Number.isFinite(rawBudget) ? rawBudget : null;
|
||||
const response = result.response;
|
||||
const isSse = response.headers.get("content-type")?.includes("event-stream") ?? false;
|
||||
|
||||
if (!isSse) {
|
||||
// Non-streaming JSON: rewrite in a buffered pass.
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
try {
|
||||
const text = await response.clone().text();
|
||||
let out = text;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
normalizeMuseSparkFinishReason(parsed, budget);
|
||||
out = JSON.stringify(parsed);
|
||||
} catch {
|
||||
/* not JSON — forward verbatim */
|
||||
}
|
||||
controller.enqueue(new TextEncoder().encode(out));
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
return;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
response: new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming SSE: line-buffered passthrough with finish_reason rewriting.
|
||||
const normalizer = createMuseSparkStreamFinishNormalizer(budget);
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = "";
|
||||
const reader = response.body.getReader();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
if (buffer.length > 0) controller.enqueue(encoder.encode(normalizer(buffer)));
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) controller.enqueue(encoder.encode(normalizer(line) + "\n"));
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
},
|
||||
cancel(reason) {
|
||||
reader.cancel(reason).catch(() => undefined);
|
||||
},
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
response: new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model);
|
||||
|
||||
@@ -254,6 +445,14 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
try {
|
||||
// muse-spark reasoning models consume the entire output budget on hidden
|
||||
// server-side reasoning; small caller budgets come back as empty-message
|
||||
// 200s ("Provider returned empty content"). Raise tiny budgets to the
|
||||
// floor before dispatch (see MUSE_SPARK_MIN_OUTPUT_TOKENS).
|
||||
if (input.body && typeof input.body === "object" && !Array.isArray(input.body)) {
|
||||
applyMuseSparkMinOutputTokens(String(input.model ?? ""), input.body as Record<string, unknown>);
|
||||
}
|
||||
|
||||
this.syncAccountsFromCredentials(input.credentials);
|
||||
const { log } = input;
|
||||
|
||||
@@ -279,7 +478,7 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
"OPENCODE",
|
||||
`upstream empty rejection on direct account (${chatcmplId}), retrying once…`
|
||||
);
|
||||
return await super.execute(input);
|
||||
return this.normalizeMuseSparkResponse(input, await super.execute(input));
|
||||
}
|
||||
log?.debug?.(
|
||||
"OPENCODE",
|
||||
@@ -287,7 +486,7 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
);
|
||||
}
|
||||
}
|
||||
return single;
|
||||
return this.normalizeMuseSparkResponse(input, single);
|
||||
}
|
||||
|
||||
// This loop only ever dispatches through super.execute() (the HTTP request
|
||||
@@ -417,7 +616,7 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
this.markSuccess(account);
|
||||
return result;
|
||||
return this.normalizeMuseSparkResponse(input, result);
|
||||
}
|
||||
|
||||
// The loop exhausted without a result. If it's because every remaining
|
||||
@@ -431,7 +630,10 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
// All accounts returned 429 (or errored) — surface the last response.
|
||||
return lastResult ?? (await super.execute(input));
|
||||
return this.normalizeMuseSparkResponse(
|
||||
input,
|
||||
lastResult ?? (await super.execute(input))
|
||||
);
|
||||
} finally {
|
||||
this._requestFormat = null;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHe
|
||||
import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts";
|
||||
import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts";
|
||||
import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts";
|
||||
import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts";
|
||||
import {
|
||||
applyReasoningInputPolicy,
|
||||
resolveIncompatibleReasoningAction,
|
||||
} from "../services/reasoningInputPolicy.ts";
|
||||
import {
|
||||
createRoutingEvent,
|
||||
emitRoutingEvent,
|
||||
@@ -1213,7 +1216,11 @@ export async function handleChatCore({
|
||||
provider,
|
||||
preserveEncryptedReasoning:
|
||||
credentials?.providerSpecificData?.preserveEncryptedReasoning === true,
|
||||
onIncompatibleReasoning: reasoningTransportFallback === "skip" ? "reject" : "drop",
|
||||
onIncompatibleReasoning: resolveIncompatibleReasoningAction({
|
||||
reasoningTransportFallback,
|
||||
isComboStep: Boolean(comboStepId || comboExecutionKey),
|
||||
headers: clientRawRequest?.headers ?? null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (policy.incompatibleReasoning) {
|
||||
|
||||
@@ -91,6 +91,14 @@ interface KieImageOptions {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap<string, string> = new Map([
|
||||
["google-imagen/nano-banana-2", "nano-banana-2"],
|
||||
]);
|
||||
|
||||
export function resolveKieMarketUpstreamModelId(publicModelId: string): string {
|
||||
return KIE_MARKET_UPSTREAM_MODEL_IDS.get(publicModelId) ?? publicModelId;
|
||||
}
|
||||
|
||||
const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([
|
||||
"black-forest-labs/FLUX.2-max",
|
||||
"black-forest-labs/FLUX.2-pro",
|
||||
@@ -205,7 +213,9 @@ function isCodexChatGptModelAccessError(status: number, errorText: string, model
|
||||
if (typeof nested === "string") detail = nested;
|
||||
}
|
||||
}
|
||||
return detail === `The '${model}' model is not supported when using Codex with a ChatGPT account.`;
|
||||
return (
|
||||
detail === `The '${model}' model is not supported when using Codex with a ChatGPT account.`
|
||||
);
|
||||
}
|
||||
|
||||
const BFL_MODEL_ENDPOINTS = {
|
||||
@@ -773,7 +783,7 @@ async function handleKieImageGeneration({
|
||||
input.image_url = imageUrl;
|
||||
}
|
||||
payload = {
|
||||
model,
|
||||
model: resolveKieMarketUpstreamModelId(model),
|
||||
input,
|
||||
};
|
||||
} else {
|
||||
|
||||
@@ -187,6 +187,12 @@ export function engineToCompressFn(engineId: string): CompressFn {
|
||||
return async (text: string): Promise<string> => {
|
||||
const body: Record<string, unknown> = {
|
||||
messages: [{ role: "user", content: text }],
|
||||
// #7746 follow-up: CCR only compresses for callers that advertise the
|
||||
// omniroute_ccr_retrieve tool (otherwise its content-addressed marker is
|
||||
// unresolvable). Real CCR traffic always carries this tool, so the
|
||||
// benchmark must too, or CCR measures as a no-op. Other engines ignore
|
||||
// the `tools` field, so this is inert for them.
|
||||
tools: [{ type: "function", function: { name: "omniroute_ccr_retrieve" } }],
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -199,6 +205,16 @@ export function engineToCompressFn(engineId: string): CompressFn {
|
||||
|
||||
const messages = result.body["messages"];
|
||||
if (Array.isArray(messages) && messages.length > 0) {
|
||||
// CCR may inject a leading [CCR protocol] system instruction, so the
|
||||
// compressed user text is not necessarily messages[0]. Prefer the LAST
|
||||
// message with string content (the user turn we fed in); fall back to
|
||||
// the first string content otherwise.
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const c = (messages[i] as Record<string, unknown>)["content"];
|
||||
if (typeof c === "string" && (messages[i] as Record<string, unknown>)["role"] !== "system") {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
const content = (messages[0] as Record<string, unknown>)["content"];
|
||||
if (typeof content === "string") return content;
|
||||
}
|
||||
|
||||
@@ -344,3 +344,65 @@ export function applyReasoningInputPolicy(
|
||||
}
|
||||
return { incompatibleReasoning: false };
|
||||
}
|
||||
|
||||
export function createReasoningTransportIncompatibleError(): Error & {
|
||||
statusCode: number;
|
||||
errorType: string;
|
||||
} {
|
||||
const error = new Error(
|
||||
"Reasoning continuation is not compatible with the selected target"
|
||||
) as Error & { statusCode: number; errorType: string };
|
||||
error.statusCode = 400;
|
||||
error.errorType = "reasoning_transport_incompatible";
|
||||
return error;
|
||||
}
|
||||
|
||||
export const REASONING_FALLBACK_HEADER = "x-omniroute-reasoning-fallback";
|
||||
|
||||
function readFallbackHeader(
|
||||
headers: Headers | Record<string, unknown> | null | undefined
|
||||
): string | null {
|
||||
if (!headers) return null;
|
||||
if (headers instanceof Headers) {
|
||||
const value = headers.get(REASONING_FALLBACK_HEADER);
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
if (typeof headers !== "object") return null;
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === REASONING_FALLBACK_HEADER && typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the action taken when inbound continuation reasoning is incompatible with the selected
|
||||
* target's reasoning transport. Combo steps keep their explicit configuration. Single-target
|
||||
* requests default to "drop" so replayed summary-only reasoning from agentic clients does not
|
||||
* hard-fail every continuation turn; an operator (OMNIROUTE_SINGLE_TARGET_REASONING_FALLBACK=reject)
|
||||
* or caller (x-omniroute-reasoning-fallback: reject) may explicitly enforce "reject".
|
||||
*/
|
||||
export function resolveIncompatibleReasoningAction(options: {
|
||||
reasoningTransportFallback?: string | null;
|
||||
isComboStep?: boolean;
|
||||
headers?: Headers | Record<string, unknown> | null;
|
||||
env?: Record<string, string | undefined>;
|
||||
}): "drop" | "reject" {
|
||||
if (options.reasoningTransportFallback === "drop") return "drop";
|
||||
if (options.isComboStep && options.reasoningTransportFallback === "skip") return "reject";
|
||||
|
||||
const headerRaw = readFallbackHeader(options.headers)?.trim().toLowerCase();
|
||||
if (headerRaw === "reject") return "reject";
|
||||
if (headerRaw === "drop") return "drop";
|
||||
|
||||
const envRaw = (
|
||||
options.env ?? process.env
|
||||
).OMNIROUTE_SINGLE_TARGET_REASONING_FALLBACK?.trim().toLowerCase();
|
||||
if (envRaw === "reject") return "reject";
|
||||
if (envRaw === "drop") return "drop";
|
||||
|
||||
// Default to "drop" for single-target requests so multi-turn agentic loops on direct
|
||||
// Codex / OpenAI targets work seamlessly out of the box.
|
||||
return "drop";
|
||||
}
|
||||
|
||||
@@ -334,6 +334,15 @@ export function translateRequest(
|
||||
const isKimiCoding =
|
||||
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
|
||||
|
||||
// GLM-family upstreams (Z.AI / Zhipu console gateways) reject messages arrays
|
||||
// with no role:"user" turn (400 [1214] "The messages parameter is illegal").
|
||||
// Pure tool-loop continuations from coding agents produce exactly that shape
|
||||
// after Claude→OpenAI conversion, so flag those providers to have the source→
|
||||
// openai translator append a synthetic user turn when none survives.
|
||||
const isGlmFamilyUpstream =
|
||||
["opencode-go", "opencode-zen"].includes(normalizedProvider) ||
|
||||
/glm|zhipu|z-ai/i.test(normalizedModel);
|
||||
|
||||
// Phase 2: Apply thinking budget control before normalization
|
||||
result = applyThinkingBudget(result);
|
||||
// Explicit reasoning-routing policies are final. The marker is internal and is
|
||||
@@ -463,13 +472,15 @@ export function translateRequest(
|
||||
options?.copilotClient ||
|
||||
hasTargetHint ||
|
||||
preserveCacheControl ||
|
||||
preserveResponsesReasoning
|
||||
preserveResponsesReasoning ||
|
||||
isGlmFamilyUpstream
|
||||
? {
|
||||
...(credentials && typeof credentials === "object" ? credentials : {}),
|
||||
...(options?.copilotClient ? { _copilotClient: true } : {}),
|
||||
...(hasTargetHint ? { _targetFormat: targetFormat } : {}),
|
||||
...(preserveCacheControl ? { _preserveCacheControl: true } : {}),
|
||||
...(preserveResponsesReasoning ? { _preserveReasoningContent: true } : {}),
|
||||
...(isGlmFamilyUpstream ? { _ensureUserTurn: true } : {}),
|
||||
}
|
||||
: credentials;
|
||||
result = toOpenAI(model, result, stream, step1Credentials);
|
||||
|
||||
@@ -191,6 +191,24 @@ export function claudeToOpenAIRequest(model, body, stream, credentials: unknown
|
||||
// unanswered tool_call receives a "[No response received]" placeholder.
|
||||
fixMissingToolResponses(result.messages);
|
||||
|
||||
// GLM-family gateways (Z.AI / Zhipu — fronted by opencode-go / opencode-zen /
|
||||
// glm-* targets) reject any payload whose messages array has NO role:"user"
|
||||
// turn with `400 [1214] The messages parameter is illegal`. Claude Code agent
|
||||
// loops legitimately produce such payloads: every inbound user turn carries
|
||||
// only tool_result blocks (translated to role:"tool") and context compression
|
||||
// can evict the original prompt. When the caller flags a GLM-family upstream
|
||||
// (_ensureUserTurn), append a minimal synthetic user turn so the request
|
||||
// satisfies the validator. Appending at the end keeps every earlier byte
|
||||
// identical for upstream prompt caches.
|
||||
const ensureUserTurn =
|
||||
credentials !== null &&
|
||||
typeof credentials === "object" &&
|
||||
!Array.isArray(credentials) &&
|
||||
(credentials as JsonRecord)._ensureUserTurn === true;
|
||||
if (ensureUserTurn && !result.messages.some((m) => m && m.role === "user")) {
|
||||
result.messages.push({ role: "user", content: "(continue)" });
|
||||
}
|
||||
|
||||
const useNativeResponsesWebSearch = shouldUseNativeResponsesWebSearch(credentials);
|
||||
|
||||
// Tools
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.8.50",
|
||||
"description": "Unified AI router with 350 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
|
||||
"description": "Unified AI router with 351 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"omniroute": "bin/omniroute.mjs",
|
||||
|
||||
162
scripts/build/buildToolRunner.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* OmniRoute — cross-platform spawning of locally installed build tools.
|
||||
*
|
||||
* WHY: `node_modules/.bin/<tool>` (no extension) is a POSIX shell script. On
|
||||
* Windows the executable shim is `<tool>.cmd`, so `execFileSync(join(ROOT,
|
||||
* "node_modules", ".bin", "esbuild"), …)` dies with
|
||||
*
|
||||
* Error: spawnSync C:\…\node_modules\.bin\esbuild ENOENT
|
||||
*
|
||||
* and — because the `postbuild` hook runs after a SUCCESSFUL `next build` — the
|
||||
* operator sees "✓ Compiled successfully" immediately followed by a failed
|
||||
* `npm run build`, with a complete `.build/next/standalone` tree on disk.
|
||||
*
|
||||
* Switching to `<tool>.cmd` alone is not enough: since the CVE-2024-27980
|
||||
* hardening, Node >= 20 refuses to spawn a `.cmd`/`.bat` without a shell
|
||||
* (EINVAL), and `shell: true` in turn disables argument escaping (DEP0190).
|
||||
*
|
||||
* So the preferred path avoids the shim entirely: read the tool's own `bin`
|
||||
* entry from its package.json and run THAT with this Node binary — no shim, no
|
||||
* shell, nothing to escape, identical behaviour on every platform. The `.bin`
|
||||
* shim stays only as a last resort for a tool that is not resolvable inside the
|
||||
* local dependency tree.
|
||||
*
|
||||
* These helpers were private to `scripts/build/prepublish.ts`, where the same
|
||||
* Windows failure was already fixed; they live here so plain-`node` build
|
||||
* scripts (`postbuild` → colocate-standalone.mjs) can share one implementation
|
||||
* instead of re-learning the same lesson. `planBuildToolSpawn()` takes the
|
||||
* platform as a parameter — like `resolveNextBuildEnv()` in
|
||||
* build-next-isolated.mjs — so the Windows behaviour is unit-testable from CI's
|
||||
* Linux runners.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
||||
|
||||
/**
|
||||
* Absolute path of a tool's own `bin` entry inside the local dependency tree,
|
||||
* or `null` when the package (or the entry it advertises) is not there.
|
||||
*
|
||||
* @param {string} packageName Package that ships the tool, e.g. `"esbuild"`.
|
||||
* @param {string} binName Key in that package's `bin` map, e.g. `"esbuild"`.
|
||||
* @param {string} [root] Directory holding `node_modules` (defaults to repo root).
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function resolveLocalBinEntry(packageName, binName, root = ROOT) {
|
||||
try {
|
||||
const packageJsonPath = join(root, "node_modules", packageName, "package.json");
|
||||
if (!existsSync(packageJsonPath)) return null;
|
||||
const meta = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
const relative = typeof meta.bin === "string" ? meta.bin : meta.bin?.[binName];
|
||||
if (!relative) return null;
|
||||
const absolute = join(root, "node_modules", packageName, relative);
|
||||
return existsSync(absolute) ? absolute : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this file start with an executable image's magic bytes?
|
||||
*
|
||||
* esbuild >= 0.25 ships `bin/esbuild` as the NATIVE platform executable on
|
||||
* Linux/macOS (ELF / Mach-O) instead of a JS shim — handing that to
|
||||
* `process.execPath` makes Node parse machine code as JavaScript and die with
|
||||
* "SyntaxError: Invalid or unexpected token". Native entries must be executed
|
||||
* directly; JS entries go through this Node binary.
|
||||
*
|
||||
* @param {string} entryPath
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isNativeExecutable(entryPath) {
|
||||
try {
|
||||
const fd = openSync(entryPath, "r");
|
||||
const head = Buffer.alloc(4);
|
||||
readSync(fd, head, 0, 4, 0);
|
||||
closeSync(fd);
|
||||
return (
|
||||
(head[0] === 0x7f && head[1] === 0x45 && head[2] === 0x4c && head[3] === 0x46) || // ELF
|
||||
head.readUInt32BE(0) === 0xfeedfacf || // Mach-O 64
|
||||
head.readUInt32BE(0) === 0xcffaedfe || // Mach-O 64 (LE on disk)
|
||||
(head[0] === 0x4d && head[1] === 0x5a) // PE (Windows MZ)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `cmd.exe` receives one flat command line, and Node does NOT escape arguments
|
||||
* when `shell` is set, so anything holding whitespace has to be quoted here.
|
||||
* Build arguments carry absolute paths, and `C:\Users\First Last\…` is an
|
||||
* ordinary Windows home directory.
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function quoteForShell(value) {
|
||||
if (!/\s/.test(value) || value.startsWith('"')) return value;
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide HOW to spawn a build tool. Pure: no filesystem access, no `process`
|
||||
* inspection beyond `execPath`, platform injected — so a Linux test can assert
|
||||
* the Windows plan.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.binName Tool name as it appears in `node_modules/.bin`.
|
||||
* @param {readonly string[]} input.args Arguments for the tool.
|
||||
* @param {string | null} [input.entryPath] Result of {@link resolveLocalBinEntry}.
|
||||
* @param {boolean} [input.entryIsNative] Result of {@link isNativeExecutable}.
|
||||
* @param {string} [input.root] Directory holding `node_modules`.
|
||||
* @param {string} [input.platform] `process.platform` value to plan for.
|
||||
* @returns {{ file: string, args: string[], shell: boolean }} `file`/`args` are
|
||||
* already shell-quoted when `shell` is true, and must be passed together.
|
||||
*/
|
||||
export function planBuildToolSpawn({
|
||||
binName,
|
||||
args,
|
||||
entryPath = null,
|
||||
entryIsNative = false,
|
||||
root = ROOT,
|
||||
platform = process.platform,
|
||||
}) {
|
||||
// Preferred: the tool's own entry point, spawned with no shim and no shell.
|
||||
if (entryPath) {
|
||||
return entryIsNative
|
||||
? { file: entryPath, args: [...args], shell: false }
|
||||
: { file: process.execPath, args: [entryPath, ...args], shell: false };
|
||||
}
|
||||
|
||||
// Last resort: the `node_modules/.bin` shim. On Windows that means the `.cmd`
|
||||
// variant, which Node only spawns through a shell (see the module header).
|
||||
const isWindows = platform === "win32";
|
||||
const shim = join(root, "node_modules", ".bin", isWindows ? `${binName}.cmd` : binName);
|
||||
return isWindows
|
||||
? { file: quoteForShell(shim), args: args.map(quoteForShell), shell: true }
|
||||
: { file: shim, args: [...args], shell: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a locally installed build tool, synchronously, on any platform.
|
||||
*
|
||||
* @param {string} packageName Package that ships the tool, e.g. `"esbuild"`.
|
||||
* @param {string} binName Key in that package's `bin` map, e.g. `"esbuild"`.
|
||||
* @param {readonly string[]} args Arguments for the tool.
|
||||
* @param {import("node:child_process").ExecFileSyncOptions} [options] Passed to `execFileSync`.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function runBuildTool(packageName, binName, args, options = {}) {
|
||||
const entryPath = resolveLocalBinEntry(packageName, binName);
|
||||
const plan = planBuildToolSpawn({
|
||||
binName,
|
||||
args,
|
||||
entryPath,
|
||||
entryIsNative: entryPath ? isNativeExecutable(entryPath) : false,
|
||||
});
|
||||
execFileSync(plan.file, plan.args, plan.shell ? { ...options, shell: true } : options);
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
*/
|
||||
import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { runBuildTool } from "./buildToolRunner.mjs";
|
||||
import { computeDependencyClosure } from "./colocateOptionals.mjs";
|
||||
|
||||
const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
||||
@@ -89,8 +89,12 @@ function main() {
|
||||
|
||||
const callLogWorkerDest = join(STANDALONE, CALL_LOG_WORKER_REL);
|
||||
mkdirSync(dirname(callLogWorkerDest), { recursive: true });
|
||||
execFileSync(
|
||||
join(ROOT, "node_modules", ".bin", "esbuild"),
|
||||
// Never spawn `node_modules/.bin/esbuild` directly: that extensionless path is
|
||||
// a POSIX shell script and does not exist on Windows (ENOENT), which failed
|
||||
// `npm run build` right after a successful `next build`. See buildToolRunner.mjs.
|
||||
runBuildTool(
|
||||
"esbuild",
|
||||
"esbuild",
|
||||
[
|
||||
CALL_LOG_WORKER_SRC,
|
||||
"--bundle",
|
||||
@@ -120,8 +124,9 @@ function main() {
|
||||
if (!existsSync(workerDest)) {
|
||||
mkdirSync(dirname(workerDest), { recursive: true });
|
||||
try {
|
||||
execFileSync(
|
||||
join(ROOT, "node_modules", ".bin", "esbuild"),
|
||||
runBuildTool(
|
||||
"esbuild",
|
||||
"esbuild",
|
||||
[
|
||||
join(
|
||||
ROOT,
|
||||
|
||||
@@ -22,14 +22,12 @@ import {
|
||||
readdirSync,
|
||||
statSync,
|
||||
chmodSync,
|
||||
openSync,
|
||||
readSync,
|
||||
closeSync,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { assembleStandalone } from "./assembleStandalone.mjs";
|
||||
import { isNativeExecutable, resolveLocalBinEntry } from "./buildToolRunner.mjs";
|
||||
import { resolveBundledNpmEntry } from "./resolveNpmEntry.ts";
|
||||
import {
|
||||
APP_STAGING_ALLOWED_EXACT_PATHS,
|
||||
@@ -51,52 +49,15 @@ const NPX_BIN = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
//
|
||||
// `shell: true` would fix the spawn but disables argument escaping (DEP0190), so it
|
||||
// is only the last resort. Preferred order: run the tool's own JS entry point with
|
||||
// this Node binary — no shim, no shell, nothing to escape.
|
||||
function resolveLocalBinEntry(packageName: string, binName: string): string | null {
|
||||
try {
|
||||
const packageJsonPath = join(ROOT, "node_modules", packageName, "package.json");
|
||||
if (!existsSync(packageJsonPath)) return null;
|
||||
const meta = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
bin?: string | Record<string, string>;
|
||||
};
|
||||
const relative = typeof meta.bin === "string" ? meta.bin : meta.bin?.[binName];
|
||||
if (!relative) return null;
|
||||
const absolute = join(ROOT, "node_modules", packageName, relative);
|
||||
return existsSync(absolute) ? absolute : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// this Node binary — no shim, no shell, nothing to escape. `resolveLocalBinEntry()`
|
||||
// and `isNativeExecutable()` implement that resolution and now live in
|
||||
// buildToolRunner.mjs, shared with the plain-`node` build scripts.
|
||||
|
||||
/**
|
||||
* Runs a build tool without ever touching a `.cmd` shim. `packageName` is where the
|
||||
* tool lives in the local dependency tree; when it is not installed there the call
|
||||
* falls back to the Node-resolved `npx` entry point, and only then to the shim.
|
||||
*/
|
||||
/**
|
||||
* esbuild ≥0.25 ships its `bin/esbuild` as the NATIVE platform executable on
|
||||
* Linux/macOS (ELF / Mach-O) instead of a JS shim — running it through
|
||||
* `process.execPath` makes Node parse machine code as JavaScript and crash with
|
||||
* "SyntaxError: Invalid or unexpected token". Sniff the magic bytes and exec
|
||||
* native entries directly; JS entries keep going through this Node binary.
|
||||
*/
|
||||
function isNativeExecutable(entryPath: string): boolean {
|
||||
try {
|
||||
const fd = openSync(entryPath, "r");
|
||||
const head = Buffer.alloc(4);
|
||||
readSync(fd, head, 0, 4, 0);
|
||||
closeSync(fd);
|
||||
return (
|
||||
(head[0] === 0x7f && head[1] === 0x45 && head[2] === 0x4c && head[3] === 0x46) || // ELF
|
||||
head.readUInt32BE(0) === 0xfeedfacf || // Mach-O 64
|
||||
head.readUInt32BE(0) === 0xcffaedfe || // Mach-O 64 (LE on disk)
|
||||
(head[0] === 0x4d && head[1] === 0x5a) // PE (Windows MZ)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runBuildTool(
|
||||
packageName: string,
|
||||
binName: string,
|
||||
|
||||
@@ -1250,6 +1250,22 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Guided connection header (#11228): /v1 URL + test action lead; advanced protocols demoted */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-text-muted">{t("subtitle")}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<code className="text-sm bg-card-subtle px-3 py-1 rounded-md text-text-main font-mono">
|
||||
{displayBaseUrl}/v1
|
||||
</code>
|
||||
<a href="#test" className="text-sm text-action font-medium hover:underline">
|
||||
{t("testEndpoint")}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span>{t("advancedProtocols")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
options={ENDPOINT_TABS.map((tab) => ({ ...tab, label: t(tab.labelKey) }))}
|
||||
value={activeEndpointTab}
|
||||
@@ -2360,19 +2376,7 @@ function ProviderModelsModal({
|
||||
<div className="flex flex-col gap-1">
|
||||
{groupModels.map((m) => {
|
||||
const copyKey = `modal-${m.id}`;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
<h1 className="text-2xl font-bold">{t("endpoint.title")}</h1>
|
||||
<p className="text-text-muted">{t("endpoint.subtitle")}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<code className="text-sm bg-card-subtle px-3 py-1 rounded-md text-text-main font-mono">{useDisplayBaseUrl()}/v1</code>
|
||||
<a href="#test" className="text-sm text-action font-medium hover:underline">{t("endpoint.testEndpoint")}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span>{t("endpoint.advancedProtocols")}</span>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-surface/60 group"
|
||||
|
||||
@@ -33,10 +33,14 @@ export default function QdrantConfigCard() {
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"" | "saved" | "error">("");
|
||||
const [health, setHealth] = useState<{ ok: boolean; latencyMs: number; error?: string } | null>(
|
||||
null
|
||||
);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [health, setHealth] = useState<{
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
collection?: { exists: boolean; vectorSize?: number; vectorName?: string | null };
|
||||
} | null>(null);
|
||||
const [searchValidated, setSearchValidated] = useState(false);
|
||||
const [tutorialOpen, setTutorialOpen] = useState(false); const [checking, setChecking] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
@@ -105,7 +109,7 @@ export default function QdrantConfigCard() {
|
||||
// invalidate in-flight checks so they cannot overwrite the new state.
|
||||
healthSeqRef.current += 1;
|
||||
setHealth(null);
|
||||
setQdrant(next);
|
||||
setSearchValidated(false); setQdrant(next);
|
||||
setSaving(true);
|
||||
setSaveStatus("");
|
||||
try {
|
||||
@@ -149,8 +153,7 @@ export default function QdrantConfigCard() {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[qdrant, checkHealth]
|
||||
);
|
||||
[qdrant, checkHealth] );
|
||||
|
||||
// Auto-check on mount once settings load: without this the status badge
|
||||
// renders red after a page refresh because `health` starts as null and the
|
||||
@@ -176,9 +179,13 @@ export default function QdrantConfigCard() {
|
||||
const data = await res.json().catch(() => null);
|
||||
if (res.ok && data?.ok) {
|
||||
setSearchResults(Array.isArray(data.results) ? data.results : []);
|
||||
setSearchValidated(true);
|
||||
} else {
|
||||
setSearchValidated(false);
|
||||
}
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
setSearchValidated(false);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
@@ -221,6 +228,14 @@ export default function QdrantConfigCard() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-text-main">{t("qdrant.title")}</h3>
|
||||
<p className="text-xs text-text-muted">{t("qdrant.description")}</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="qdrant-setup-tutorial"
|
||||
onClick={() => setTutorialOpen(true)}
|
||||
className="mt-1 text-xs font-medium text-emerald-500 hover:underline"
|
||||
>
|
||||
Como configurar o Qdrant corretamente
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium ${
|
||||
@@ -230,8 +245,7 @@ export default function QdrantConfigCard() {
|
||||
? "text-text-muted"
|
||||
: health.ok
|
||||
? "text-emerald-500"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
: "text-red-500" }`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full ${
|
||||
@@ -277,7 +291,7 @@ export default function QdrantConfigCard() {
|
||||
<button
|
||||
data-testid="qdrant-enabled-switch"
|
||||
onClick={() => save({ enabled: !qdrant.enabled })}
|
||||
disabled={saving}
|
||||
disabled={saving || (!qdrant.enabled && !searchValidated)}
|
||||
role="switch"
|
||||
aria-checked={qdrant.enabled}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
@@ -293,6 +307,13 @@ export default function QdrantConfigCard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!qdrant.enabled && !searchValidated && (
|
||||
<p className="-mt-2 mb-4 text-xs text-amber-500">
|
||||
Execute um teste de busca bem-sucedido antes de ativar. Ele valida o modelo de embedding e
|
||||
a dimensão da coleção.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{health && (
|
||||
<div
|
||||
className={`mb-4 text-xs font-medium flex items-center gap-1 ${health.ok ? "text-emerald-500" : "text-red-500"}`}
|
||||
@@ -305,6 +326,23 @@ export default function QdrantConfigCard() {
|
||||
: (health.error ?? t("qdrant.healthError"))}
|
||||
</div>
|
||||
)}
|
||||
{health?.collection && (
|
||||
<div className="mb-4 rounded-lg border border-border/50 bg-surface/30 p-3 text-xs text-text-muted">
|
||||
{health.collection.exists ? (
|
||||
<>
|
||||
Coleção compatível com vetores de dimensão{" "}
|
||||
<strong>{health.collection.vectorSize}</strong>
|
||||
{health.collection.vectorName ? ` (vetor: ${health.collection.vectorName})` : ""}. O
|
||||
modelo configurado deve gerar a mesma dimensão.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
A coleção ainda não existe. Ela será criada na primeira gravação com o modelo
|
||||
validado.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveStatus === "saved" && (
|
||||
<div className="mb-4 text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
@@ -472,6 +510,50 @@ export default function QdrantConfigCard() {
|
||||
</div>
|
||||
{cleanupMsg && <p className="mt-2 text-xs text-text-muted">{cleanupMsg}</p>}
|
||||
</div>
|
||||
{tutorialOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Tutorial de configuração do Qdrant"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
>
|
||||
<div className="max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-border bg-background p-6 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold">Tutorial rápido: memória com Qdrant</h4>
|
||||
<p className="mt-2 text-sm text-text-muted">
|
||||
O Qdrant guarda vetores e metadados das memórias para recuperar contexto
|
||||
relevante. Ele não comprime tokens diretamente; a economia é indireta, ao evitar
|
||||
contexto sem relação com a solicitação.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTutorialOpen(false)}
|
||||
aria-label="Fechar tutorial"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<ol className="mt-5 list-decimal space-y-3 pl-5 text-sm text-text-muted">
|
||||
<li>Proteja o servidor com HTTPS e API key antes de uso produtivo.</li>
|
||||
<li>
|
||||
Informe host, porta, coleção e um modelo no formato provider/model com credencial
|
||||
configurada.
|
||||
</li>
|
||||
<li>
|
||||
A dimensão da coleção precisa ser igual à dimensão produzida pelo modelo. Uma
|
||||
coleção existente de 2048 dimensões não aceita embeddings de 1536 dimensões.
|
||||
</li>
|
||||
<li>Salve, teste a conexão e execute o teste de busca. Só então ative o Qdrant.</li>
|
||||
</ol>
|
||||
<pre className="mt-5 overflow-x-auto rounded-lg bg-surface p-3 text-xs">{`PUT /collections/minha_memoria\n{\n "vectors": { "size": <dimensão-do-modelo>, "distance": "Cosine" }\n}`}</pre>
|
||||
<p className="mt-5 text-xs text-text-muted">
|
||||
Créditos: Rafa Martins — rafacpti@gmail.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ export default function ConnectionsListPanel({
|
||||
? (enabled) => handleToggleConnectionAutoSync(conn.id, enabled)
|
||||
: undefined
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isCodex={providerId === "codex" || providerId === "codex-app-server"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCliproxyapiMode={(enabled) => handleToggleCliproxyapiMode(conn.id, enabled)}
|
||||
@@ -610,7 +610,7 @@ export default function ConnectionsListPanel({
|
||||
? (enabled) => handleToggleConnectionAutoSync(conn.id, enabled)
|
||||
: undefined
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isCodex={providerId === "codex" || providerId === "codex-app-server"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCliproxyapiMode={(enabled) =>
|
||||
|
||||
@@ -96,6 +96,7 @@ export default function AddApiKeyModal({
|
||||
const isLocalSelfHostedProvider = !!localProviderMetadata;
|
||||
const isGooglePse = provider === "google-pse-search";
|
||||
const isChatGptWebCodex = provider === "chatgpt-web-codex";
|
||||
const isAwsPolly = provider === "aws-polly";
|
||||
const webSessionCredential = getWebSessionCredentialRequirement(provider);
|
||||
const isNoAuthWebSessionCredential = webSessionCredential?.kind === "none";
|
||||
const isWebSessionCredential = !!webSessionCredential && webSessionCredential.kind !== "none";
|
||||
@@ -118,6 +119,8 @@ export default function AddApiKeyModal({
|
||||
baseUrl: initialBaseUrl || defaultBaseUrl,
|
||||
cx: "",
|
||||
region: showsRegion ? defaultRegion : "",
|
||||
awsAccessKeyId: "",
|
||||
awsSessionToken: "",
|
||||
apiRegion: "international",
|
||||
validationModelId: defaultValidationModelIdForProvider(provider), // #5446 item 4 — Modal probe model pre-fill
|
||||
routingTags: "",
|
||||
@@ -178,13 +181,15 @@ export default function AddApiKeyModal({
|
||||
const [bulkWarnings, setBulkWarnings] = useState<string[]>([]);
|
||||
const apiCredentialLabel = isModal
|
||||
? providerText(t, "modalTokenIdLabel", "Token ID")
|
||||
: isQoder
|
||||
? t("personalAccessTokenLabel")
|
||||
: webSessionCredential
|
||||
? getWebSessionCredentialLabel(t, webSessionCredential, apiKeyOptional)
|
||||
: apiKeyOptional
|
||||
? `${t("apiKeyLabel")} (${t("optional").toLowerCase()})`
|
||||
: t("apiKeyLabel");
|
||||
: isAwsPolly
|
||||
? providerText(t, "awsPollySecretAccessKeyLabel", "AWS Secret Access Key")
|
||||
: isQoder
|
||||
? t("personalAccessTokenLabel")
|
||||
: webSessionCredential
|
||||
? getWebSessionCredentialLabel(t, webSessionCredential, apiKeyOptional)
|
||||
: apiKeyOptional
|
||||
? `${t("apiKeyLabel")} (${t("optional").toLowerCase()})`
|
||||
: t("apiKeyLabel");
|
||||
const apiCredentialPlaceholder = isModal
|
||||
? "ak-xxxxxxxxxxxxxxxx"
|
||||
: isVertex
|
||||
@@ -247,7 +252,13 @@ export default function AddApiKeyModal({
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
customUserAgent: formData.customUserAgent.trim() || undefined,
|
||||
baseUrl: formData.baseUrl.trim() || undefined,
|
||||
region: showsRegion ? formData.region.trim() || defaultRegion : undefined,
|
||||
region: isAwsPolly
|
||||
? formData.region.trim() || "us-east-1"
|
||||
: showsRegion
|
||||
? formData.region.trim() || defaultRegion
|
||||
: undefined,
|
||||
accessKeyId: isAwsPolly ? formData.awsAccessKeyId.trim() || undefined : undefined,
|
||||
sessionToken: isAwsPolly ? formData.awsSessionToken.trim() || undefined : undefined,
|
||||
cx: formData.cx.trim() || undefined,
|
||||
runtimeKey: isChatGptWebCodex ? formData.runtimeKey.trim() || undefined : undefined,
|
||||
tunnelId: isChatGptWebCodex ? formData.tunnelId.trim() || undefined : undefined,
|
||||
@@ -284,7 +295,12 @@ export default function AddApiKeyModal({
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const credentialInput = resolveCredentialInput();
|
||||
if (!provider || (!isCompatible && !apiKeyOptional && !credentialInput)) return;
|
||||
if (
|
||||
!provider ||
|
||||
(!isCompatible && !apiKeyOptional && !credentialInput) ||
|
||||
(isAwsPolly && !formData.awsAccessKeyId.trim())
|
||||
)
|
||||
return;
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
@@ -321,7 +337,13 @@ export default function AddApiKeyModal({
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
customUserAgent: formData.customUserAgent.trim() || undefined,
|
||||
baseUrl: formData.baseUrl.trim() || undefined,
|
||||
region: showsRegion ? formData.region.trim() || defaultRegion : undefined,
|
||||
region: isAwsPolly
|
||||
? formData.region.trim() || "us-east-1"
|
||||
: showsRegion
|
||||
? formData.region.trim() || defaultRegion
|
||||
: undefined,
|
||||
accessKeyId: isAwsPolly ? formData.awsAccessKeyId.trim() || undefined : undefined,
|
||||
sessionToken: isAwsPolly ? formData.awsSessionToken.trim() || undefined : undefined,
|
||||
cx: formData.cx.trim() || undefined,
|
||||
runtimeKey: isChatGptWebCodex ? formData.runtimeKey.trim() || undefined : undefined,
|
||||
tunnelId: isChatGptWebCodex ? formData.tunnelId.trim() || undefined : undefined,
|
||||
@@ -850,6 +872,25 @@ export default function AddApiKeyModal({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-6">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={
|
||||
(!isCompatible && !apiKeyOptional && !formData.apiKey) ||
|
||||
(isAwsPolly && !formData.awsAccessKeyId.trim()) ||
|
||||
(isGooglePse && !formData.cx.trim()) ||
|
||||
validating ||
|
||||
saving
|
||||
}
|
||||
variant="secondary"
|
||||
>
|
||||
{validating
|
||||
? t("checking")
|
||||
: webSessionCredential
|
||||
? getWebSessionCredentialCheckLabel(t, webSessionCredential)
|
||||
: t("check")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isModal && (
|
||||
@@ -869,6 +910,56 @@ export default function AddApiKeyModal({
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
)}
|
||||
{isAwsPolly && (
|
||||
<>
|
||||
<Input
|
||||
label={providerText(t, "awsPollyAccessKeyIdLabel", "AWS Access Key ID")}
|
||||
value={formData.awsAccessKeyId}
|
||||
onChange={(e) => setFormData({ ...formData, awsAccessKeyId: e.target.value })}
|
||||
placeholder="AKIA..."
|
||||
hint={providerText(
|
||||
t,
|
||||
"awsPollyAccessKeyIdHint",
|
||||
"Used with the secret access key to sign Amazon Polly requests."
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
<Input
|
||||
label={providerText(t, "awsPollyRegionLabel", "AWS Region")}
|
||||
value={formData.region}
|
||||
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||
placeholder="us-east-1"
|
||||
hint={providerText(
|
||||
t,
|
||||
"awsPollyRegionHint",
|
||||
"Defaults to us-east-1 when left blank."
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
<Input
|
||||
label={providerText(
|
||||
t,
|
||||
"awsPollySessionTokenLabel",
|
||||
"AWS Session Token (optional)"
|
||||
)}
|
||||
type="password"
|
||||
value={formData.awsSessionToken}
|
||||
onChange={(e) => setFormData({ ...formData, awsSessionToken: e.target.value })}
|
||||
hint={providerText(
|
||||
t,
|
||||
"awsPollySessionTokenHint",
|
||||
"Required only for temporary AWS credentials."
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isGooglePse && (
|
||||
<Input
|
||||
label={t("searchEngineIdLabel")}
|
||||
|
||||
@@ -115,6 +115,8 @@ export default function EditConnectionModal({
|
||||
targetFormat: "",
|
||||
cx: "",
|
||||
region: "",
|
||||
awsAccessKeyId: "",
|
||||
awsSessionToken: "",
|
||||
apiRegion: "international",
|
||||
validationModelId: "",
|
||||
defaultModel: "",
|
||||
@@ -216,6 +218,7 @@ export default function EditConnectionModal({
|
||||
const isLocalSelfHostedProvider = !!localProviderMetadata;
|
||||
const isGooglePse = provider === "google-pse-search";
|
||||
const isChatGptWebCodex = provider === "chatgpt-web-codex";
|
||||
const isAwsPolly = provider === "aws-polly";
|
||||
const isM365TierCapable = isM365TierCapableProvider(provider);
|
||||
const webSessionCredential = getWebSessionCredentialRequirement(provider);
|
||||
const isNoAuthWebSessionCredential = webSessionCredential?.kind === "none";
|
||||
@@ -232,9 +235,11 @@ export default function EditConnectionModal({
|
||||
isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider);
|
||||
const apiCredentialLabel = webSessionCredential
|
||||
? getWebSessionCredentialLabel(t, webSessionCredential, apiKeyOptional)
|
||||
: apiKeyOptional
|
||||
? t("apiKeyOptionalLabel")
|
||||
: t("apiKeyLabel");
|
||||
: isAwsPolly
|
||||
? providerText(t, "awsPollySecretAccessKeyLabel", "AWS Secret Access Key")
|
||||
: apiKeyOptional
|
||||
? t("apiKeyOptionalLabel")
|
||||
: t("apiKeyLabel");
|
||||
const apiCredentialPlaceholder = isWebSessionCredential
|
||||
? webSessionCredential.placeholder
|
||||
: isVertex
|
||||
@@ -255,6 +260,9 @@ export default function EditConnectionModal({
|
||||
const existingBaseUrl = stringField(connection.providerSpecificData?.baseUrl);
|
||||
const existingTargetFormat = stringField(connection.providerSpecificData?.targetFormat);
|
||||
const existingRegion = stringField(connection.providerSpecificData?.region);
|
||||
const existingAwsAccessKeyId =
|
||||
stringField(connection.providerSpecificData?.accessKeyId) ||
|
||||
stringField(connection.providerSpecificData?.awsAccessKeyId);
|
||||
const existingCustomUserAgent = stringField(connection.providerSpecificData?.customUserAgent);
|
||||
const existingOpenRouterPreset = stringField(connection.providerSpecificData?.preset);
|
||||
const existingCx = stringField(connection.providerSpecificData?.cx);
|
||||
@@ -313,7 +321,11 @@ export default function EditConnectionModal({
|
||||
baseUrl: existingBaseUrl || defaultBaseUrl,
|
||||
targetFormat: existingTargetFormat || "",
|
||||
cx: existingCx,
|
||||
region: existingRegion || (showsRegion ? defaultRegion : ""),
|
||||
region:
|
||||
existingRegion ||
|
||||
(effectiveProvider === "aws-polly" ? "us-east-1" : showsRegion ? defaultRegion : ""),
|
||||
awsAccessKeyId: existingAwsAccessKeyId,
|
||||
awsSessionToken: "",
|
||||
apiRegion: (connection.providerSpecificData?.apiRegion as string) || "international",
|
||||
validationModelId: (connection.providerSpecificData?.validationModelId as string) || "",
|
||||
defaultModel: (connection.defaultModel as string) || "",
|
||||
@@ -435,7 +447,8 @@ export default function EditConnectionModal({
|
||||
if (
|
||||
!provider ||
|
||||
isNoAuthWebSessionCredential ||
|
||||
(!isCompatible && !apiKeyOptional && !formData.apiKey)
|
||||
(!isCompatible && !apiKeyOptional && !formData.apiKey) ||
|
||||
(isAwsPolly && !formData.awsAccessKeyId.trim())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -451,7 +464,13 @@ export default function EditConnectionModal({
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
customUserAgent: formData.customUserAgent.trim() || undefined,
|
||||
baseUrl: formData.baseUrl.trim() || undefined,
|
||||
region: showsRegion ? formData.region.trim() || defaultRegion : undefined,
|
||||
region: isAwsPolly
|
||||
? formData.region.trim() || "us-east-1"
|
||||
: showsRegion
|
||||
? formData.region.trim() || defaultRegion
|
||||
: undefined,
|
||||
accessKeyId: isAwsPolly ? formData.awsAccessKeyId.trim() || undefined : undefined,
|
||||
sessionToken: isAwsPolly ? formData.awsSessionToken.trim() || undefined : undefined,
|
||||
cx: formData.cx.trim() || undefined,
|
||||
runtimeKey: isChatGptWebCodex ? formData.runtimeKey.trim() || undefined : undefined,
|
||||
tunnelId: isChatGptWebCodex ? formData.tunnelId.trim() || undefined : undefined,
|
||||
@@ -550,7 +569,13 @@ export default function EditConnectionModal({
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
customUserAgent: formData.customUserAgent.trim() || undefined,
|
||||
baseUrl: formData.baseUrl.trim() || undefined,
|
||||
region: showsRegion ? formData.region.trim() || defaultRegion : undefined,
|
||||
region: isAwsPolly
|
||||
? formData.region.trim() || "us-east-1"
|
||||
: showsRegion
|
||||
? formData.region.trim() || defaultRegion
|
||||
: undefined,
|
||||
accessKeyId: isAwsPolly ? formData.awsAccessKeyId.trim() || undefined : undefined,
|
||||
sessionToken: isAwsPolly ? formData.awsSessionToken.trim() || undefined : undefined,
|
||||
cx: formData.cx.trim() || undefined,
|
||||
runtimeKey: isChatGptWebCodex ? formData.runtimeKey.trim() || undefined : undefined,
|
||||
tunnelId: isChatGptWebCodex ? formData.tunnelId.trim() || undefined : undefined,
|
||||
@@ -935,6 +960,7 @@ export default function EditConnectionModal({
|
||||
onClick={handleValidate}
|
||||
disabled={
|
||||
(!isCompatible && !apiKeyOptional && !formData.apiKey) ||
|
||||
(isAwsPolly && !formData.awsAccessKeyId.trim()) ||
|
||||
(isGooglePse && !formData.cx.trim()) ||
|
||||
validating ||
|
||||
saving
|
||||
@@ -1036,6 +1062,56 @@ export default function EditConnectionModal({
|
||||
hint={t("searchEngineIdHint")}
|
||||
/>
|
||||
)}
|
||||
{isAwsPolly && (
|
||||
<>
|
||||
<Input
|
||||
label={providerText(t, "awsPollyAccessKeyIdLabel", "AWS Access Key ID")}
|
||||
value={formData.awsAccessKeyId}
|
||||
onChange={(e) => setFormData({ ...formData, awsAccessKeyId: e.target.value })}
|
||||
placeholder="AKIA..."
|
||||
hint={providerText(
|
||||
t,
|
||||
"awsPollyAccessKeyIdHint",
|
||||
"Used with the secret access key to sign Amazon Polly requests."
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
<Input
|
||||
label={providerText(t, "awsPollyRegionLabel", "AWS Region")}
|
||||
value={formData.region}
|
||||
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||
placeholder="us-east-1"
|
||||
hint={providerText(
|
||||
t,
|
||||
"awsPollyRegionHint",
|
||||
"Defaults to us-east-1 when left blank."
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
<Input
|
||||
label={providerText(
|
||||
t,
|
||||
"awsPollySessionTokenLabel",
|
||||
"AWS Session Token (optional)"
|
||||
)}
|
||||
type="password"
|
||||
value={formData.awsSessionToken}
|
||||
onChange={(e) => setFormData({ ...formData, awsSessionToken: e.target.value })}
|
||||
hint={providerText(
|
||||
t,
|
||||
"awsPollySessionTokenHint",
|
||||
"Required only for temporary AWS credentials."
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? t("valid") : t("invalid")}
|
||||
|
||||
@@ -17,6 +17,8 @@ type FormData = QuotaScrapingFieldValues &
|
||||
GlmTeamQuotaFieldValues & {
|
||||
accountId: string;
|
||||
apiRegion: string;
|
||||
awsAccessKeyId: string;
|
||||
awsSessionToken: string;
|
||||
ccCompatibleContext1m: boolean;
|
||||
ccCompatibleRedactThinking: boolean;
|
||||
ccCompatibleSummarizeThinking: boolean;
|
||||
@@ -90,7 +92,11 @@ export function buildAddProviderSpecificData(options: {
|
||||
}
|
||||
assignQuotaScrapingProviderData(provider, formData, data);
|
||||
if (isGooglePse && formData.cx.trim()) data.cx = formData.cx.trim();
|
||||
if (usesBaseUrl) data.baseUrl = validatedBaseUrl;
|
||||
if (provider === "aws-polly") {
|
||||
data.accessKeyId = formData.awsAccessKeyId.trim() || undefined;
|
||||
data.region = formData.region.trim() || "us-east-1";
|
||||
data.sessionToken = formData.awsSessionToken.trim() || undefined;
|
||||
} else if (usesBaseUrl) data.baseUrl = validatedBaseUrl;
|
||||
if (showsRegion) data.region = formData.region?.trim() || defaultRegion;
|
||||
else if (isGlm) {
|
||||
data.apiRegion = formData.apiRegion;
|
||||
@@ -150,7 +156,11 @@ export function assignEditApiKeyProviderSpecificData(options: {
|
||||
assignQuotaScrapingProviderData(o.provider, o.formData, o.target);
|
||||
if (o.formData.validationModelId) o.target.validationModelId = o.formData.validationModelId;
|
||||
if (o.isGooglePse) o.target.cx = o.formData.cx.trim() || undefined;
|
||||
if (o.usesBaseUrl) o.target.baseUrl = o.validatedBaseUrl;
|
||||
if (o.provider === "aws-polly") {
|
||||
o.target.accessKeyId = o.formData.awsAccessKeyId.trim() || undefined;
|
||||
o.target.region = o.formData.region.trim() || "us-east-1";
|
||||
o.target.sessionToken = o.formData.awsSessionToken.trim() || undefined;
|
||||
} else if (o.usesBaseUrl) o.target.baseUrl = o.validatedBaseUrl;
|
||||
if (o.showsRegion) o.target.region = o.formData.region?.trim() || o.defaultRegion;
|
||||
else if (o.isGlm) {
|
||||
o.target.apiRegion = o.formData.apiRegion;
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime";
|
||||
import { CodexAuthFileError, writeCodexAuthFileToLocalCli } from "@/lib/oauth/utils/codexAuthFile";
|
||||
import {
|
||||
CodexAuthFileError,
|
||||
writeCodexAuthFileToLocalCliIfNeeded,
|
||||
} from "@/lib/oauth/utils/codexAuthFile";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
// Optional body { force?: boolean }. Unknown keys are stripped rather than
|
||||
// rejected so the endpoint stays tolerant of the empty/no-body calls it
|
||||
// historically accepted. Non-boolean `force` is coerced away to the default.
|
||||
const ApplyLocalBodySchema = z
|
||||
.object({ force: z.boolean().optional() })
|
||||
.partial()
|
||||
.passthrough();
|
||||
|
||||
function toErrorResponse(error: unknown) {
|
||||
if (error instanceof CodexAuthFileError) {
|
||||
return NextResponse.json(
|
||||
@@ -33,7 +45,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const result = await writeCodexAuthFileToLocalCli(id);
|
||||
|
||||
// Optional { force?: boolean } body. By default we DON'T clobber an existing,
|
||||
// fresh ~/.codex/auth.json (a session the user may be managing themselves);
|
||||
// force overwrites it (a backup is always taken regardless). Malformed/empty
|
||||
// bodies are tolerated — this endpoint historically took no body.
|
||||
let force = false;
|
||||
try {
|
||||
const parsed = ApplyLocalBodySchema.safeParse(await request.json());
|
||||
force = parsed.success ? parsed.data.force === true : false;
|
||||
} catch {
|
||||
/* no body — default force=false */
|
||||
}
|
||||
|
||||
const applied = await writeCodexAuthFileToLocalCliIfNeeded(id, { force });
|
||||
const result = applied.result;
|
||||
|
||||
logAuditEvent({
|
||||
action: "provider.credentials.applied",
|
||||
@@ -45,18 +71,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
requestId: auditContext.requestId,
|
||||
metadata: {
|
||||
provider: "codex",
|
||||
authPath: result.authPath,
|
||||
savedBakPath: result.savedBakPath,
|
||||
decision: applied.decision,
|
||||
authPath: applied.authPath,
|
||||
savedBakPath: result?.savedBakPath,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connectionId: id,
|
||||
connectionLabel: result.connectionLabel,
|
||||
authPath: result.authPath,
|
||||
savedBakPath: result.savedBakPath,
|
||||
centralizedBackupPath: result.centralizedBackupPath,
|
||||
// "skipped_present_fresh" means an existing healthy auth.json was kept.
|
||||
decision: applied.decision,
|
||||
connectionLabel: result?.connectionLabel,
|
||||
authPath: applied.authPath,
|
||||
savedBakPath: result?.savedBakPath,
|
||||
centralizedBackupPath: result?.centralizedBackupPath,
|
||||
writtenAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -165,14 +165,19 @@ function buildCodexDiscoveryModel(record: JsonRecord): CodexDiscoveryModel | nul
|
||||
apiFormat: "responses",
|
||||
supportedEndpoints: ["responses"],
|
||||
};
|
||||
// The live Codex OAuth catalog reports BOTH `context_window` (the first
|
||||
// pricing tier, ~272K) and `max_context_window` (the real usable window,
|
||||
// ~872K). Requests well past the pricing tier succeed upstream, so the max
|
||||
// window must win whenever it is present; `context_window` is only a
|
||||
// fallback for catalogs that omit the max.
|
||||
const inputTokenLimit = firstPositiveNumber(
|
||||
record.inputTokenLimit,
|
||||
record.maxInputTokens,
|
||||
record.max_input_tokens,
|
||||
record.contextLength,
|
||||
record.context_length,
|
||||
record.context_window,
|
||||
record.max_context_window,
|
||||
record.context_window,
|
||||
topProvider.context_length,
|
||||
limits.input_tokens,
|
||||
limits.inputTokenLimit,
|
||||
|
||||
136
src/app/api/providers/[id]/test/codexAppServerHealth.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Build the structured diagnosis object the connection-test route returns.
|
||||
* Lives here (rather than inline in test/route.ts) so both the route and the
|
||||
* codex-app-server health probe share one definition. Pure.
|
||||
*/
|
||||
export function makeDiagnosis(
|
||||
type: string,
|
||||
source: string,
|
||||
message: string | null,
|
||||
code: string | null = null
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
source,
|
||||
message: message || null,
|
||||
code: code ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export type CodexAppServerHealth = {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
diagnosis: unknown;
|
||||
refreshed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A codex "app-server" connection (providerSpecificData.codexTransport ===
|
||||
* "app-server") does NOT carry a validatable OpenAI token: it drives the codex
|
||||
* CLI's own `codex app-server` process over JSON-RPC/WebSocket, and THAT process
|
||||
* self-manages its OpenAI OAuth (its own ~/.codex/auth.json), exactly like an
|
||||
* interactive codex session. So the ordinary OAuth token probe is meaningless for
|
||||
* these connections — it validates a placeholder and reports a false "Token
|
||||
* invalid or revoked" 401 (which then trips the rate-limit cooldown on retest).
|
||||
*
|
||||
* The correct health signal for this transport is whether the app-server itself
|
||||
* is reachable and ready. The app-server exposes an unauthenticated liveness
|
||||
* endpoint at <httpBase>/readyz (200 = ready) alongside its ws:// listener, so we
|
||||
* derive the http(s) origin from the configured ws(s):// URL and probe /readyz.
|
||||
* Returns null when this connection is NOT an app-server connection (so the caller
|
||||
* falls through to the normal token validation).
|
||||
*/
|
||||
export async function testCodexAppServerConnection(
|
||||
connection: any
|
||||
): Promise<CodexAppServerHealth | null> {
|
||||
const psd = (connection?.providerSpecificData as Record<string, unknown> | undefined) || undefined;
|
||||
// Fire the /readyz probe when EITHER (a) the connection opted into the
|
||||
// app-server transport via the per-connection flag (a `codex` provider
|
||||
// connection with codexTransport==="app-server"), OR (b) this is the
|
||||
// first-class `codex-app-server` provider, which is app-server by definition
|
||||
// and needs no flag. Otherwise return null so the caller falls through to the
|
||||
// normal OAuth/apikey token validation.
|
||||
const isAppServerProvider = connection?.provider === "codex-app-server";
|
||||
const isAppServerFlag = psd?.codexTransport === "app-server";
|
||||
if (!isAppServerProvider && !isAppServerFlag) return null;
|
||||
|
||||
// Dynamic import (not a static top-level import) so this executor-config module
|
||||
// stays behind the open-sse boundary the no-restricted-imports lint rule enforces.
|
||||
const { resolveAppServerConfig } = await import(
|
||||
"@omniroute/open-sse/executors/codex/appServerConfig.ts"
|
||||
);
|
||||
const config = resolveAppServerConfig(psd);
|
||||
if (!config) {
|
||||
const error = "Codex app-server transport is not configured (missing url or token)";
|
||||
return {
|
||||
valid: false,
|
||||
error,
|
||||
refreshed: false,
|
||||
diagnosis: makeDiagnosis("validation_error", "local", error, "app_server_unconfigured"),
|
||||
};
|
||||
}
|
||||
|
||||
// ws://host:port → http://host:port/readyz ; wss:// → https://.
|
||||
const httpBase = config.url.replace(/^ws(s?):\/\//i, (_m, s) => `http${s}://`).replace(/\/+$/, "");
|
||||
const readyzUrl = `${httpBase}/readyz`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const res = await fetch(readyzUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${config.token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
const error = `Codex app-server not ready (${readyzUrl} → HTTP ${res.status})`;
|
||||
return {
|
||||
valid: false,
|
||||
error,
|
||||
refreshed: false,
|
||||
diagnosis: makeDiagnosis("provider_error", "app_server", error, "app_server_not_ready"),
|
||||
};
|
||||
}
|
||||
// The server PROCESS is up. Now confirm its Codex CLI is actually SIGNED IN —
|
||||
// /readyz alone would show green for a logged-out CLI, which then fails on the
|
||||
// first real turn. Probe account/read over the JSON-RPC WebSocket.
|
||||
let authStatus;
|
||||
try {
|
||||
const [{ probeCodexAppServerAuth }, { getCodexAppServerWebsocketTransport }] =
|
||||
await Promise.all([
|
||||
import("@omniroute/open-sse/executors/codex/appServerAuthProbe.ts"),
|
||||
import("@omniroute/open-sse/executors/codex.ts"),
|
||||
]);
|
||||
authStatus = await probeCodexAppServerAuth(config, getCodexAppServerWebsocketTransport(), 8000);
|
||||
} catch (probeErr: any) {
|
||||
// If the auth probe itself fails to load/run, don't fail the whole health
|
||||
// check — the server IS reachable. Treat as unknown-but-reachable (valid).
|
||||
authStatus = { state: "unknown", reason: probeErr?.message ?? "auth probe failed" } as const;
|
||||
}
|
||||
|
||||
if (authStatus.state === "logged_out") {
|
||||
const error =
|
||||
"Codex app-server is running but its Codex CLI is not signed in. Use \u201cSign in with ChatGPT\u201d to authenticate.";
|
||||
return {
|
||||
valid: false,
|
||||
error,
|
||||
refreshed: false,
|
||||
diagnosis: makeDiagnosis("auth_required", "app_server", error, "app_server_login_required"),
|
||||
};
|
||||
}
|
||||
// "authenticated" → healthy; "unknown" (probe unavailable/timed out) → treat
|
||||
// the reachable server as healthy rather than blocking on an inconclusive probe.
|
||||
return { valid: true, refreshed: false, diagnosis: null };
|
||||
} catch (err: any) {
|
||||
const reason = err?.name === "AbortError" ? "timed out" : (err?.message ?? "unreachable");
|
||||
const error = `Codex app-server unreachable (${readyzUrl}: ${reason})`;
|
||||
return {
|
||||
valid: false,
|
||||
error,
|
||||
refreshed: false,
|
||||
diagnosis: makeDiagnosis("provider_error", "app_server", error, "app_server_unreachable"),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/lib/oauth/gitlab";
|
||||
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
||||
import { shouldUseApiKeyConnectionTest } from "./webSessionTestDispatch";
|
||||
import { testCodexAppServerConnection, makeDiagnosis } from "./codexAppServerHealth";
|
||||
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
|
||||
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
|
||||
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
|
||||
@@ -52,20 +53,6 @@ function toSafeMessage(value: any, fallback = "Unknown error"): string {
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
function makeDiagnosis(
|
||||
type: string,
|
||||
source: string,
|
||||
message: string | null,
|
||||
code: string | null = null
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
source,
|
||||
message: message || null,
|
||||
code: code ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider/account that the upstream has deactivated (vs. a revoked/expired token).
|
||||
* #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT
|
||||
@@ -1024,6 +1011,13 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
const startTime = Date.now();
|
||||
const runtime = await getProviderRuntimeStatus(connection);
|
||||
|
||||
// Codex app-server connections carry no validatable OpenAI token (the codex
|
||||
// app-server process self-manages its own OAuth). Probe the app-server's
|
||||
// /readyz liveness endpoint instead of the meaningless token check — otherwise
|
||||
// every sweep reports a false "Token invalid or revoked" 401 and cools the
|
||||
// connection down. Returns null for non-app-server connections (fall through).
|
||||
const appServerResult = await testCodexAppServerConnection(connection);
|
||||
|
||||
if ((runtime as any)?.diagnosis) {
|
||||
result = {
|
||||
valid: false,
|
||||
@@ -1031,6 +1025,10 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
refreshed: false,
|
||||
diagnosis: (runtime as any).diagnosis,
|
||||
};
|
||||
} else if (appServerResult) {
|
||||
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
Promise.resolve(appServerResult)
|
||||
);
|
||||
} else if (shouldUseApiKeyConnectionTest(connection.authType, provider)) {
|
||||
const enrichedConnection = validationModelId
|
||||
? {
|
||||
|
||||