Compare commits
73 Commits
fix/8307-c
...
fix/codeql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e046429a6 | ||
|
|
25ba4f2a34 | ||
|
|
c40ff16a1d | ||
|
|
0b51a242ce | ||
|
|
7f90af645c | ||
|
|
e6801bace1 | ||
|
|
82ed31d27a | ||
|
|
dacf4c3c1a | ||
|
|
362c5acbfe | ||
|
|
7fd82eb146 | ||
|
|
6f28688b04 | ||
|
|
12d0acbe06 | ||
|
|
61051a1460 | ||
|
|
1fb466a1ee | ||
|
|
2c84ce19df | ||
|
|
e5b7c40d11 | ||
|
|
053c64d380 | ||
|
|
3112304db6 | ||
|
|
a280bfc112 | ||
|
|
d99701d6b3 | ||
|
|
80b517edea | ||
|
|
a72dc25c04 | ||
|
|
621f30a188 | ||
|
|
ff9a4c2fbd | ||
|
|
f52fa9dc85 | ||
|
|
424b950856 | ||
|
|
01b3828278 | ||
|
|
998c3c2129 | ||
|
|
b59a88b7eb | ||
|
|
54b39690e5 | ||
|
|
22e46a0875 | ||
|
|
2a10d16114 | ||
|
|
06315c445c | ||
|
|
31031f93ef | ||
|
|
c6a0d09bcd | ||
|
|
b052c91014 | ||
|
|
bbcfb730ca | ||
|
|
db7c3abaf6 | ||
|
|
567b9db04d | ||
|
|
8bc2f0f10c | ||
|
|
8c4a219746 | ||
|
|
7288fa0dd7 | ||
|
|
d14a4d2da1 | ||
|
|
2acafd9c9e | ||
|
|
9d2240eab7 | ||
|
|
9eddafff60 | ||
|
|
7c6bf32186 | ||
|
|
56b9d00335 | ||
|
|
2f7315882b | ||
|
|
77d75022d6 | ||
|
|
6f08a089e7 | ||
|
|
8b52596d7c | ||
|
|
6767f27011 | ||
|
|
7ac6bbba37 | ||
|
|
84d7e33c26 | ||
|
|
80a59c0ae5 | ||
|
|
8122f6b71c | ||
|
|
5e508147c4 | ||
|
|
a352c23bad | ||
|
|
4821f9ffdb | ||
|
|
f4772500bc | ||
|
|
b43ad73166 | ||
|
|
bb98e9a345 | ||
|
|
ff8b7b172f | ||
|
|
0bfaaa4929 | ||
|
|
4c15c05f9b | ||
|
|
8cd248b4f5 | ||
|
|
05a37634c2 | ||
|
|
62f6e87869 | ||
|
|
74c54828fc | ||
|
|
f060117464 | ||
|
|
9fc3b29217 | ||
|
|
e9dd87ad77 |
21
.env.example
@@ -229,6 +229,15 @@ PORT=20128
|
||||
# unaffected by this dev-only flag).
|
||||
OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
# Disable systemd sd_notify (Type=notify / WatchdogSec=) even when running
|
||||
# under a systemd unit with NOTIFY_SOCKET set.
|
||||
# Used by: scripts/dev/systemd-notify.mjs. Set to 1 to disable.
|
||||
# OMNIROUTE_DISABLE_SD_NOTIFY=1
|
||||
|
||||
# Injected by systemd when running under a service unit (sd_notify protocol).
|
||||
# Read by scripts/dev/systemd-notify.mjs — never set this yourself.
|
||||
# NOTIFY_SOCKET=/run/systemd/notify
|
||||
|
||||
# Skip the SQLite integrity health check on startup (faster boot on large DBs).
|
||||
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip.
|
||||
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
|
||||
@@ -882,7 +891,7 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# MODEL_SYNC_INTERVAL_HOURS=24
|
||||
|
||||
# Provider limits sync interval in minutes (rate limit windows, quotas).
|
||||
# Used by: src/server-init.ts — polls provider health endpoints.
|
||||
# Used by: src/lib/usage/providerLimits.ts — polls provider health endpoints.
|
||||
# Default: 70
|
||||
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
|
||||
|
||||
@@ -1979,6 +1988,16 @@ APP_LOG_TO_FILE=true
|
||||
# Reachability probe target for the scheduler and the auto-test endpoint.
|
||||
# Point it at an internal/self-hosted URL to avoid the public default.
|
||||
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
|
||||
# Probes started at once per batch, for the scheduler and the auto-test endpoint.
|
||||
# Floored at 1 and capped at 50. Default: 10.
|
||||
# PROXY_HEALTH_TEST_CONCURRENCY=10
|
||||
# Delay in ms between two probe departures inside a batch. Without it the whole batch
|
||||
# leaves at once and a shared egress IP can trip a rate-limited target. 0 disables the
|
||||
# spacing; capped at 5000. Default: 100.
|
||||
# PROXY_HEALTH_TEST_STAGGER_MS=100
|
||||
# Set "false" to stop probing the real host of a proxy's assigned provider (GET /models,
|
||||
# no API key) and always use the generic target above instead. Default: enabled.
|
||||
# PROXY_HEALTH_USE_PROVIDER_TARGET=true
|
||||
# Set "true" to let the scheduler auto-remove proxies after repeated failures.
|
||||
# PROXY_AUTO_REMOVE=false
|
||||
# Consecutive failures before an auto-remove fires. Default: 3.
|
||||
|
||||
@@ -1294,10 +1294,15 @@ export function mapRawModelToModelV2(
|
||||
// `(providerID, modelID)`. If the raw id is already provider-prefixed
|
||||
// (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or
|
||||
// `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave
|
||||
// it as-is — double-prefixing breaks OC's lookup. Otherwise prefix with
|
||||
// the resolved `providerId` so a bare key like `claude-opus-4` parses as
|
||||
// `(omniroute, claude-opus-4)` and the credentials resolve correctly.
|
||||
id: raw.id.includes("/") ? raw.id : `${ctx.providerId}/${raw.id}`,
|
||||
// it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids
|
||||
// (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed:
|
||||
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under
|
||||
// the plugin provider (#10345). Other bare ids still prefix with
|
||||
// `providerId` so credentials resolve as `(omniroute, model)`.
|
||||
id:
|
||||
raw.id.includes("/") || raw.owned_by === "combo"
|
||||
? raw.id
|
||||
: `${ctx.providerId}/${raw.id}`,
|
||||
/**
|
||||
* Display name. Falls back to raw.id when no enrichment is available;
|
||||
* the caller (`createOmniRouteProviderHook`) overlays
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { mapRawModelToModelV2 } from "../src/index.ts";
|
||||
|
||||
test("mapRawModelToModelV2: bare combo ids stay unprefixed (#10345)", () => {
|
||||
const combo = mapRawModelToModelV2(
|
||||
{
|
||||
id: "gpt-5.6-sol",
|
||||
owned_by: "combo",
|
||||
context_length: 272000,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(combo.id, "gpt-5.6-sol");
|
||||
assert.equal(combo.providerID, "omniroute");
|
||||
|
||||
const slashed = mapRawModelToModelV2(
|
||||
{
|
||||
id: "cx/gpt-5.6-sol",
|
||||
owned_by: "combo",
|
||||
context_length: 272000,
|
||||
},
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(slashed.id, "cx/gpt-5.6-sol");
|
||||
|
||||
const ordinary = mapRawModelToModelV2(
|
||||
{ id: "claude-primary", context_length: 200000 },
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(ordinary.id, "omniroute/claude-primary");
|
||||
});
|
||||
@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 342 LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 343 LLM providers, auto-fallback.
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
16
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 → 342 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. 342 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 → 343 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. 343 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<tr>
|
||||
<td align="right"><b>⚙️ Features</b></td>
|
||||
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
|
||||
<td align="center"><a href="#-342-ai-providers--90-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-343-ai-providers--90-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI & MCP</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -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. 342 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 342 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 109 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 343 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 343 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 109 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: 342 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 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: 343 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 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>
|
||||
|
||||
@@ -559,7 +559,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **342-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **343-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
- **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
|
||||
|
||||
@@ -642,11 +642,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 🌐 342 AI Providers — 90+ Free
|
||||
## 🌐 343 AI Providers — 90+ Free
|
||||
|
||||
</div>
|
||||
|
||||
> The most complete catalog of any open-source router: **342 providers**, **90+ with a free tier**, **56 free forever**.
|
||||
> The most complete catalog of any open-source router: **343 providers**, **90+ with a free tier**, **56 free forever**.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -988,6 +988,8 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
|
||||
-p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
|
||||
```
|
||||
|
||||
`:latest` follows the highest **published** stable SemVer. It does not track git `main`. Pin `:X.Y.Z` for GitOps. See [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).
|
||||
|
||||
> **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and
|
||||
> `diegosouzapw/omniroute:next-web` follow the current default `release/v*`
|
||||
> branch. These mutable tags are intended only for testing unreleased fixes and
|
||||
|
||||
@@ -52,6 +52,19 @@ function resolveUrl(path, opts) {
|
||||
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/** The machine-derived token is valid only for the local loopback server. */
|
||||
export function isLoopbackUrl(value) {
|
||||
try {
|
||||
const hostname = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (hostname === "localhost" || hostname === "::1") return true;
|
||||
if (/^127(?:\.[0-9]{1,3}){3}$/.test(hostname)) return true;
|
||||
if (/^::ffff:(?:127\.|7f[0-9a-f]{2}:)/i.test(hostname)) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildHeaders(opts) {
|
||||
const headers = new Headers(opts.headers || {});
|
||||
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||
@@ -87,10 +100,17 @@ export async function buildHeaders(opts) {
|
||||
if (auth && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${auth}`);
|
||||
}
|
||||
// Inject machine-id derived CLI token; env var override for testing.
|
||||
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
|
||||
if (cliToken && !headers.has(CLI_TOKEN_HEADER)) {
|
||||
headers.set(CLI_TOKEN_HEADER, cliToken);
|
||||
// Inject the machine-derived credential only for an explicit local loopback
|
||||
// destination. Remote contexts and absolute remote URLs use scoped access
|
||||
// tokens and must never receive this machine-bound local credential.
|
||||
const destinationUrl = opts.destinationUrl ?? getBaseUrl(opts);
|
||||
if (!isLoopbackUrl(destinationUrl)) {
|
||||
headers.delete(CLI_TOKEN_HEADER);
|
||||
} else {
|
||||
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
|
||||
if (cliToken && !headers.has(CLI_TOKEN_HEADER)) {
|
||||
headers.set(CLI_TOKEN_HEADER, cliToken);
|
||||
}
|
||||
}
|
||||
if (opts.idempotencyKey && !headers.has("idempotency-key")) {
|
||||
headers.set("idempotency-key", opts.idempotencyKey);
|
||||
@@ -195,8 +215,12 @@ function fetchOnce(url, init, timeoutMs) {
|
||||
export async function apiFetch(path, opts = {}) {
|
||||
const method = String(opts.method || "GET").toUpperCase();
|
||||
const url = resolveUrl(path, opts);
|
||||
const headers = await buildHeaders(opts);
|
||||
const headers = await buildHeaders({ ...opts, destinationUrl: url });
|
||||
const body = serializeBody(opts.body, headers);
|
||||
// Undici preserves custom headers across cross-origin redirects. A local server
|
||||
// redirect must never turn the loopback machine credential into an outbound
|
||||
// secret, so fail redirects whenever this header is present.
|
||||
const redirect = headers.has(CLI_TOKEN_HEADER) ? "error" : opts.redirect;
|
||||
const timeout =
|
||||
opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000);
|
||||
const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts);
|
||||
@@ -205,7 +229,7 @@ export async function apiFetch(path, opts = {}) {
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const res = await fetchOnce(url, { method, headers, body }, timeout);
|
||||
const res = await fetchOnce(url, { method, headers, body, redirect }, timeout);
|
||||
if (res.ok) return enrichResponse(res, opts);
|
||||
if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) {
|
||||
const delay = computeBackoff(attempt, res.headers.get("retry-after"));
|
||||
|
||||
@@ -4,7 +4,9 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createDecipheriv, scryptSync } from "node:crypto";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { isLoopbackUrl } from "../api.mjs";
|
||||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||
import { getCliToken, CLI_TOKEN_HEADER } from "../utils/cliToken.mjs";
|
||||
import { printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
|
||||
@@ -378,11 +380,11 @@ function checkMemory() {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url) {
|
||||
async function fetchWithTimeout(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { signal: controller.signal });
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
@@ -471,6 +473,98 @@ async function checkServerLiveness(options = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkMachineTokenAuth(options = {}) {
|
||||
if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") {
|
||||
return warn("CLI machine token", "CLI machine-token authentication is disabled", {
|
||||
derived: false,
|
||||
accepted: false,
|
||||
disabled: true,
|
||||
tokenExposed: false,
|
||||
});
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
const parsed = new URL(resolveLivenessUrl(options));
|
||||
if (
|
||||
!["http:", "https:"].includes(parsed.protocol) ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
!isLoopbackUrl(parsed.toString())
|
||||
) {
|
||||
return warn(
|
||||
"CLI machine token",
|
||||
"Machine-token probes are limited to HTTP(S) loopback endpoints",
|
||||
{ derived: false, accepted: false, tokenExposed: false }
|
||||
);
|
||||
}
|
||||
parsed.pathname = "/api/cli/whoami";
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
url = parsed.toString();
|
||||
} catch {
|
||||
return warn("CLI machine token", "Could not resolve the management endpoint", {
|
||||
derived: false,
|
||||
accepted: false,
|
||||
tokenExposed: false,
|
||||
});
|
||||
}
|
||||
|
||||
const token = await getCliToken();
|
||||
if (!token) {
|
||||
return fail(
|
||||
"CLI machine token",
|
||||
"Could not derive a machine token; verify the node-machine-id runtime is installed",
|
||||
{ derived: false, accepted: false, tokenExposed: false }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
headers: { [CLI_TOKEN_HEADER]: token },
|
||||
redirect: "error",
|
||||
});
|
||||
if (response.ok) {
|
||||
return ok("CLI machine token", "Server accepted the local machine token", {
|
||||
url,
|
||||
status: response.status,
|
||||
derived: true,
|
||||
accepted: true,
|
||||
tokenExposed: false,
|
||||
});
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return warn(
|
||||
"CLI machine token",
|
||||
"Server rejected the local machine token; if the CLI and server are on different hosts or container boundaries, run `omniroute connect <host> --key <oma_live_...>`",
|
||||
{
|
||||
url,
|
||||
status: response.status,
|
||||
derived: true,
|
||||
accepted: false,
|
||||
containerBoundaryLikely: true,
|
||||
tokenExposed: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
return warn("CLI machine token", `Machine-token probe returned HTTP ${response.status}`, {
|
||||
url,
|
||||
status: response.status,
|
||||
derived: true,
|
||||
accepted: false,
|
||||
tokenExposed: false,
|
||||
});
|
||||
} catch {
|
||||
return warn("CLI machine token", "Machine-token endpoint could not be reached", {
|
||||
url,
|
||||
status: 0,
|
||||
derived: true,
|
||||
accepted: false,
|
||||
tokenExposed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
const rootDir =
|
||||
context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
@@ -488,6 +582,7 @@ export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
|
||||
if (!options.skipLiveness) {
|
||||
checks.push(await checkServerLiveness(options));
|
||||
checks.push(await checkMachineTokenAuth(options));
|
||||
}
|
||||
|
||||
// CLI tool health checks
|
||||
|
||||
@@ -121,7 +121,16 @@ function writeLinuxSystemdUnit(cliPath) {
|
||||
"Wants=network-online.target",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=simple",
|
||||
// Type=notify + WatchdogSec: the server sends READY=1 once listening and
|
||||
// WATCHDOG=1 every 60s; if its event loop ever blocks (frozen process),
|
||||
// the pings stop and systemd kills+restarts the service. NotifyAccess=all
|
||||
// because the pings come from the server child, not the serve supervisor.
|
||||
// Foreground serve only: `--daemon` escapes the cgroup and would break
|
||||
// the notify handshake.
|
||||
"Type=notify",
|
||||
"NotifyAccess=all",
|
||||
"WatchdogSec=180",
|
||||
"TimeoutStartSec=300",
|
||||
`ExecStart=${buildServeExecLine(cliPath, { tray: false })}`,
|
||||
"Restart=on-failure",
|
||||
"RestartSec=5",
|
||||
|
||||
@@ -12,25 +12,39 @@ function getActiveSalt() {
|
||||
return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT;
|
||||
}
|
||||
|
||||
export async function getCliToken() {
|
||||
const salt = getActiveSalt();
|
||||
if (_cached !== null && _cachedSalt === salt) return _cached;
|
||||
export function deriveCliToken(machineIdModule, salt) {
|
||||
try {
|
||||
// node-machine-id is CommonJS: under `await import()` its exports land on
|
||||
// `.default`, so destructuring `machineIdSync` off the namespace yields
|
||||
// undefined and calling it throws — which the catch below turned into an
|
||||
// empty token, silently disabling CLI auth for every management request.
|
||||
// Same resolution order as src/lib/machineToken.ts.
|
||||
const mod = await import("node-machine-id");
|
||||
const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync;
|
||||
if (typeof machineIdSync !== "function") throw new Error("machine-id API unavailable");
|
||||
const machineIdSync =
|
||||
machineIdModule?.machineIdSync || machineIdModule?.default?.machineIdSync;
|
||||
if (typeof machineIdSync !== "function") return "";
|
||||
// machineIdSync(true) returns the original unhashed hardware ID — mirrors
|
||||
// getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening).
|
||||
const mid = machineIdSync(true);
|
||||
_cached = crypto.createHmac("sha256", mid).update(salt).digest("hex");
|
||||
const rawId = machineIdSync(true);
|
||||
if (!rawId) return "";
|
||||
return crypto.createHmac("sha256", rawId).update(salt).digest("hex");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCliToken() {
|
||||
const salt = getActiveSalt();
|
||||
if (_cached !== null && _cachedSalt === salt) return _cached;
|
||||
try {
|
||||
const imported = await import("node-machine-id");
|
||||
const token = deriveCliToken(imported, salt);
|
||||
if (!token) {
|
||||
// Swallowing here changes control flow (every management call goes out
|
||||
// unauthenticated and 401s), so leave a breadcrumb rather than failing mute.
|
||||
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled");
|
||||
}
|
||||
_cached = token;
|
||||
} catch (e) {
|
||||
// Swallowing here changes control flow (every management call goes out
|
||||
// unauthenticated and 401s), so leave a breadcrumb rather than failing mute.
|
||||
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled:", e);
|
||||
_cached = "";
|
||||
}
|
||||
|
||||
@@ -119,6 +119,9 @@ function loadEnvFile() {
|
||||
addEnvPath(join(ROOT, ".env"));
|
||||
}
|
||||
|
||||
const keyOrigin = new Map();
|
||||
const shadowed = new Map();
|
||||
|
||||
for (const envPath of envPaths) {
|
||||
try {
|
||||
if (existsSync(envPath)) {
|
||||
@@ -131,19 +134,31 @@ function loadEnvFile() {
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1));
|
||||
keyOrigin.set(key, envPath);
|
||||
} else if (!shadowed.has(key)) {
|
||||
// The line is inert: something set this key first. Report it once
|
||||
// per key, whether the winner was an earlier file or the process
|
||||
// environment (#6194: a shell's own HOSTNAME beat the .env and the
|
||||
// server bound to the wrong address in silence).
|
||||
shadowed.set(key, { winner: keyOrigin.get(key) ?? null, loser: envPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
loadedEnvPaths.push(envPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors reading env files.
|
||||
} catch (err) {
|
||||
console.warn(` \x1b[33m⚠ Could not read ${envPath}: ${err?.message ?? err}\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const envPath of loadedEnvPaths) {
|
||||
console.log(` \x1b[2m📋 Loaded env from ${envPath}\x1b[0m`);
|
||||
}
|
||||
|
||||
for (const [key, { winner, loser }] of shadowed) {
|
||||
const setter = winner ? winner : "the environment";
|
||||
console.warn(` \x1b[33m⚠ ${key} in ${loser} is ignored, ${setter} set it first\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile();
|
||||
|
||||
1
changelog.d/features/10303-healthz-event-loop-lag.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(resilience):** warn when `/healthz` is served under event-loop lag ≥200ms so a slow 200 is visible as sick, not healthy ([#10303](https://github.com/diegosouzapw/OmniRoute/issues/10303))
|
||||
1
changelog.d/features/10316-livez-endpoint.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(docker):** add `GET`/`HEAD` `/livez` as a process-alive probe, distinct from `/healthz` readiness ([#10316](https://github.com/diegosouzapw/OmniRoute/issues/10316))
|
||||
1
changelog.d/features/10587-ogg-speech-alias.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** accept `response_format=ogg` on `/v1/audio/speech` as an alias for the existing Opus/Ogg encoder ([#10587](https://github.com/diegosouzapw/OmniRoute/issues/10587))
|
||||
1
changelog.d/features/10662-systemd-notify.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(server): emit systemd sd_notify READY/WATCHDOG/STOPPING (generated unit becomes Type=notify with WatchdogSec=180) so a frozen server process is killed and restarted by systemd instead of lingering undetected
|
||||
1
changelog.d/features/10670-call-logs-error-type.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(call_logs):** persist the per-call error family in `call_logs.error_type` and expose a failure breakdown (`errorBreakdown`) in the usage analytics endpoint, reusing the existing production classifier ([#10670](https://github.com/diegosouzapw/OmniRoute/issues/10670))
|
||||
1
changelog.d/features/10677-egress-sharing-summary.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxy):** the proxy-health sweep and `GET /api/settings/proxies/egress` now report an anonymous summary of egress-IP sharing — how many rotation groups share an egress IP and the largest number of accounts behind one IP — computed from persisted `proxy_logs` over a 24h window. No IPs and no account identities by default; `PROXY_LOG_INCLUDE_IPS=true` restores raw details. ([#10677](https://github.com/diegosouzapw/OmniRoute/issues/10677))
|
||||
1
changelog.d/features/10869-combo-patch-verb.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(api): accept PATCH on /api/combos/[id], the verb the OpenAPI spec already documents (#10869)
|
||||
@@ -0,0 +1,2 @@
|
||||
- **feat(credential-health):** pace the credential health sweep per connection via `provider_connections.healthCheckInterval` (minutes, 0 = never), with `CREDENTIAL_HEALTH_CHECK_INTERVAL` as the global default ([#8443](https://github.com/diegosouzapw/OmniRoute/issues/8443))
|
||||
- **behavior change:** `healthCheckInterval` is a shared column — it paces both the OAuth token refresh and the credential health sweep, and `0` disables both. The connection editor defaults it to 60, so configured OAuth connections are now credential-checked at 60min instead of the previous ~10min (aligned with the probe-volume goal of #8443)
|
||||
1
changelog.d/features/cursor-agent-image-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(sse): add Cursor plan image generation via Agent CLI (`IMAGE_PROVIDERS.cursor`, format `cursor-agent-image`), reusing the chat Cursor OAuth connection
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(routing):** keep approximate Combo context estimates advisory so requests reach concrete targets instead of returning a pre-dispatch 400 ([#10162](https://github.com/diegosouzapw/OmniRoute/pull/10162)) — thanks @xz-dev
|
||||
1
changelog.d/fixes/10345-bare-combo-opencode-ids.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(opencode-plugin):** publish bare combo model ids without the plugin provider prefix so OpenCode can select them ([#10345](https://github.com/diegosouzapw/OmniRoute/issues/10345))
|
||||
1
changelog.d/fixes/10346-empty-pool-warn-once.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(backend):** log `auto/<family> matched no connected models` once per process per label instead of every minute ([#10346](https://github.com/diegosouzapw/OmniRoute/issues/10346))
|
||||
1
changelog.d/fixes/10353-memory-heap-conflict-warn.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(docker):** warn at boot when `OMNIROUTE_MEMORY_MB` disagrees with `NODE_OPTIONS --max-old-space-size`, and document that the standalone/Docker launcher appends `OMNIROUTE_MEMORY_MB` last ([#10353](https://github.com/diegosouzapw/OmniRoute/issues/10353))
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(antigravity):** automatically rotate to a sibling account when one is BYOP (GCP Project ID required, `gcp_project_required` 422) — the account is excluded from selection for 24h and the request succeeds via another account instead of failing fast; the actionable 422 is surfaced only when no sibling exists (follow-up to the #10424 BYOP fast-fail) ([#10470](https://github.com/diegosouzapw/OmniRoute/pull/10470)) — thanks @rqzbeh
|
||||
1
changelog.d/fixes/10686-combo-quota-token-limit-await.md
Normal file
@@ -0,0 +1 @@
|
||||
- **Combo routing:** await each connection's token limit before reserving quota. The old lookup treated the `Promise` as a connection and dropped `rateLimitOverrides.tpm` ([#10686](https://github.com/diegosouzapw/OmniRoute/pull/10686)).
|
||||
1
changelog.d/fixes/10735-search-provider-named-errors.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(search):** name `/v1/search` 502s with provider id and sanitized Node cause code, without hostnames ([#10735](https://github.com/diegosouzapw/OmniRoute/issues/10735))
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers):** Keep NVIDIA timeout probes and generic Antigravity/AGY HTTP 400 probes from poisoning credential health while preserving explicit Google geo-block handling ([#10799](https://github.com/diegosouzapw/OmniRoute/pull/10799)) — thanks @Zartharas
|
||||
1
changelog.d/fixes/10832-unprefixed-dalle3.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(images):** register OpenAI `dall-e-3` in the image registry so unprefixed `dall-e-3` (and `openai/dall-e-3`) route to OpenAI Images instead of Microsoft Designer Web, and so the chat catalog no longer lists `openai/dall-e-3` as a 128k chat model ([#10832](https://github.com/diegosouzapw/OmniRoute/issues/10832))
|
||||
1
changelog.d/fixes/10843-outbound-guard-mapped-ipv4.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(security):** Outbound URL guard now resolves IPv4-mapped IPv6 literals to their embedded address, so `[::ffff:169.254.169.254]` is refused by the unconditional cloud-metadata block like its dotted spelling; `[::]` is refused alongside `0.0.0.0` ([#10843](https://github.com/diegosouzapw/OmniRoute/pull/10843)) — thanks @ntdat812
|
||||
1
changelog.d/fixes/10853-i18n-disabled-mistranslation.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** The "Disabled" status no longer renders as the noun for a person with a disability in Japanese, Spanish, Hindi, Polish, Telugu, Urdu and both Chinese locales — 24 strings now use each catalog's existing wording (ja 無効, es Deshabilitado, hi अक्षम, pl Wyłączone, te నిలిపివేయబడింది, ur غیر فعال, zh-CN 已禁用, zh-TW 已停用) ([#10812](https://github.com/diegosouzapw/OmniRoute/issues/10812), [#10853](https://github.com/diegosouzapw/OmniRoute/pull/10853)) — thanks @ntdat812
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(catalog):** `/v1/models` no longer advertises the built-in `auto/*` ids while auto routing is disabled — they were listed but rejected at request time with `Auto routing is disabled` ([#10831](https://github.com/diegosouzapw/OmniRoute/issues/10831), [#10857](https://github.com/diegosouzapw/OmniRoute/pull/10857)) — thanks @ntdat812
|
||||
1
changelog.d/fixes/10858-base64-file-token-estimate.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(context):** Base64 file payloads (OpenAI `file` parts, Responses `input_file`, Claude `document` blocks) are budgeted like the Gemini `inlineData` path instead of being counted as prompt text — a ~1MB PDF estimated at 350k tokens and was rejected on the context limit before reaching the provider's document pipeline ([#10840](https://github.com/diegosouzapw/OmniRoute/issues/10840), [#10858](https://github.com/diegosouzapw/OmniRoute/pull/10858)) — thanks @ntdat812
|
||||
1
changelog.d/fixes/10860-mcp-upstream-fetch-timeout.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(mcp):** MCP tool calls that wait on a model provider no longer abort after 10 seconds. `omniRouteFetch` applied a single hardcoded `AbortSignal.timeout(10000)` to every internal hop, and `omniroute_route_request` — which posts to `/v1/chat/completions` and waits on the upstream provider, plus auto-combo candidate probing before a provider is even chosen — passed no signal of its own, so it inherited it. Any route slower than 10s failed from the MCP side while the identical request succeeded through the REST API. `omniroute_web_search` and `omniroute_web_fetch` in the same file already carried an explicit 60s signal, so that value is now shared by all three provider-bound calls instead of being repeated as a literal, while management reads (health, resilience, rate limits, combos, quota, usage) keep their fast-fail 10s budget so a stalled local endpoint still cannot hold a tool call open. Both budgets are overridable through `OMNIROUTE_MCP_FETCH_TIMEOUT_MS` and `OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS`, replacing the reported workaround of patching the compiled `dist/.build/next/server/chunks/*.js`; a malformed or non-positive override falls back to the default rather than disabling the timeout
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers):** importing models with an expired API key now surfaces the credential error instead of reporting "No new models were added". The Import button posts to `/api/providers/{id}/sync-models`, which self-fetches the models route; that route does not fail on an upstream 401 but degrades to a catalog it already has, preferring the cache and using the local catalog only when there is no cache. A provider that imported successfully once therefore has a cache, so an expired key produced `{ source: "cache", warning: "Models probe failed (401) — using cached catalog" }` with HTTP 200 — and the #5460/#5465 degradation guard only recognised the `local_catalog` branch, so model-sync accepted it as a successful discovery, found every cached model already imported, and returned the empty-diff result. Retest does not go through this path, which is why it failed correctly and made the import look like a genuine "nothing to do". The existing rule — a degraded discovery must not be persisted as the synced catalog — is now applied to the branch it missed rather than special-casing 401/403, discriminating on the warning the fallback builder always attaches (an ordinary non-refresh cache hit attaches none, and model-sync always requests `refresh=true`). `isDegradedLocalCatalog` keeps its exact meaning and its existing tests
|
||||
1
changelog.d/fixes/10866-combo-empty-models.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): reject a combo update that removes every model, and store the copilot's combo targets where the router reads them (#10866)
|
||||
1
changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(proxy):** proxy "Test connection" no longer reports an IPv4-only SOCKS5/SSH proxy as dead. #1255 moved every egress probe from `api.ipify.org` to `api64.ipify.org` so proxies with IPv6 egress could be tested, but `api64` is IPv6-first: a tunnel with no IPv6 route has nothing to connect to, so the probe hung until the caller's deadline and a proxy that was carrying live LLM traffic came back as a failure. Swapping the target to `api4` fixes that case and re-breaks the one #1255 fixed, so the probe now tries the targets in order instead — `api64` first, so a proxy with working IPv6 answers on the first attempt and keeps the exact behaviour #1255 introduced, including which of its addresses is reported (the egress IP is used as an identity to detect accounts of one rotation group sharing an address, so the attempts are sequential rather than raced). The attempts split the budget each call site already enforced, so no probe can take longer than it could before, and each attempt gets its own `AbortController` so exhausting the budget on an unreachable target does not abort the next one. `OMNIROUTE_PROXY_ECHO_URL` pins a single target — including a self-hosted echo — replacing the workaround of rewriting the compiled bundle after every upgrade. The relay branch of the test route still targets `api64` through `x-relay-target`, since that request egresses from the relay worker rather than the operator's tunnel
|
||||
1
changelog.d/fixes/10870-cli-env-collision.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cli): warn when a .env line never takes effect, and stop swallowing an unreadable .env (#10870)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(db):** Remove stale MiMoCode provider configuration, including the legacy `mcode` alias, left after provider retirement while preserving historical usage and call logs ([#10873](https://github.com/diegosouzapw/OmniRoute/pull/10873)) — thanks @Zartharas
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(provider-health):** Keep unsupported 404/405 validation probes neutral so they do not poison stored credential health or scheduler failure state, while still honoring per-connection health-check pacing ([#10878](https://github.com/diegosouzapw/OmniRoute/pull/10878)) — thanks @Zartharas
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(antigravity):** map Gemini 3.7 Flash tier ids (`gemini-3.7-flash-high/medium/low`, bare `gemini-3.7-flash`) to the upstream `gemini-3.7-flash-tiered` model id Google's Cloud Code endpoint expects, and configure per-tier thinking budgets ([#10882](https://github.com/diegosouzapw/OmniRoute/pull/10882)) — thanks @adevwithpurpose
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(catalog):** derive combo reasoning-effort tiers from the exact runtime-selectable connection scope, intersecting dynamic, pinned, allowlisted, and compatible provider-node evidence while failing closed on unknown capabilities.
|
||||
@@ -0,0 +1 @@
|
||||
- **docs(docker):** spell out that `:latest` tracks the highest **published** stable SemVer (not git `main`), and that GitOps should pin `X.Y.Z` ([#10317](https://github.com/diegosouzapw/OmniRoute/issues/10317))
|
||||
@@ -0,0 +1 @@
|
||||
- **docs(backend):** document that memory extraction, skills injection, and token refresh share the request event loop, plus dashboard kill switches ([#10349](https://github.com/diegosouzapw/OmniRoute/issues/10349))
|
||||
@@ -0,0 +1 @@
|
||||
- **docs(docker):** document default SQLite as single-replica / HA-unsupported, including Recreate and HEALTHCHECK session blast radius ([#10350](https://github.com/diegosouzapw/OmniRoute/issues/10350))
|
||||
@@ -0,0 +1 @@
|
||||
- **docs(backend):** document that pre-write SQLite backups (including models.dev pricing) are throttled to once per 60 minutes and can be disabled with `DISABLE_SQLITE_AUTO_BACKUP` ([#10351](https://github.com/diegosouzapw/OmniRoute/issues/10351))
|
||||
1
changelog.d/maintenance/10780-server-init-dead-code.md
Normal file
@@ -0,0 +1 @@
|
||||
- chore(startup): remove `src/server-init.ts` (183 lines, never imported — the boot path is `src/instrumentation-node.ts`) and correct four `"called from server-init.ts"` comments left pointing at the dead entry point (#10780)
|
||||
1
changelog.d/maintenance/10875-combos-id-verb-coverage.md
Normal file
@@ -0,0 +1 @@
|
||||
- **docs(openapi):** document the `GET` and `PUT` operations on `/api/combos/{id}`, and add an operation-level coverage floor so a missing verb can no longer hide behind a path that already counts as covered ([#10875](https://github.com/diegosouzapw/OmniRoute/pull/10875))
|
||||
1
changelog.d/maintenance/7786-management-auth-guide.md
Normal file
@@ -0,0 +1 @@
|
||||
- **docs(auth):** distinguish dashboard sessions, `oma_live_…` Access Tokens, manage-scoped API keys, and inference keys ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786))
|
||||
1
changelog.d/maintenance/env-doc-sync-adhoc-bot.md
Normal file
@@ -0,0 +1 @@
|
||||
- **chore(ci):** ignore ad-hoc `BOT_TOKEN`/`BOT_URL` in env-doc-sync (scripts/ad-hoc mesh helpers, not runtime config)
|
||||
@@ -54,11 +54,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"open-sse/handlers/chatCore/codexFailover.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"open-sse/handlers/chatCore/comboContextCache.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -1023,11 +1018,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/api/modelTestRunner.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/api/proxyRegistryRouteHandlers.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -1083,11 +1073,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/embeddings/service.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/evals/runtime.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@@ -2934,11 +2919,6 @@
|
||||
"count": 83
|
||||
}
|
||||
},
|
||||
"tests/unit/responses-parse-once-4041.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"tests/unit/responses-translation-fixes.test.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 35
|
||||
|
||||
@@ -304,6 +304,8 @@
|
||||
"_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.",
|
||||
"_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)",
|
||||
"frozen": {
|
||||
"_rebaseline_2026_08_20_10878_10799_provider_health_probes": "PRs #10878 (unsupported OpenAI-like validation probes stay neutral) + #10799 (preserve credential health on inconclusive NVIDIA-timeout/Antigravity-400 probes) own growth: src/app/api/providers/[id]/test/route.ts 946->1025 (+79, sum of both boarded together). Both add narrowly-scoped classification branches at the existing test-route dispatch chokepoint (unsupported-capability skip, credential-inconclusive detection) rather than new files, mirroring the prior 2026_06_27_5193 rebaseline of the same file. Covered by tests/unit/provider-validation-unsupported-neutral.test.ts + tests/unit/provider-health-inconclusive-probes.test.ts.",
|
||||
"src/app/api/providers/[id]/test/route.ts": 1025,
|
||||
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
|
||||
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
|
||||
"_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, <cap) and the async orderer orderTargetsByHeadroom is appended to the existing open-sse/services/combo/quotaStrategies.ts (<cap) next to its sibling reset-aware/reset-window orderers (reuses their connection-expansion machinery). headroom = 1 - max(util_5h, util_7d) from getSaturation (src/lib/quota/saturationSignals.ts), prefers the connection with the most free capacity. Only the dispatch wiring is irreducible at the existing combo strategy chokepoint (mirrors the reset-aware/reset-window/context-optimized branches); not extractable without hiding the call site. fill-first stays default; all existing strategies untouched. Covered by tests/unit/combo-headroom-ranking.test.ts (pure helper) + tests/unit/combo-headroom-strategy.test.ts (orderer, saturation injected). Structural shrink of combo.ts tracked in #3501.",
|
||||
@@ -447,7 +449,7 @@
|
||||
"_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).",
|
||||
"src/lib/modelCapabilities.ts": 1006,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
|
||||
"open-sse/config/imageRegistry.ts": 1019,
|
||||
"open-sse/config/imageRegistry.ts": 1034,
|
||||
"src/sse/handlers/chatHelpers.ts": 1017,
|
||||
"src/shared/middleware/chatBodyAdmission.ts": 1005
|
||||
},
|
||||
@@ -615,5 +617,7 @@
|
||||
"_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts.",
|
||||
"_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule).",
|
||||
"_rebaseline_2026_08_18_imageregistry_merge_train": "merge-train 2026-08-18 (owner-authorized, /merge-prs batch of 84): open-sse/config/imageRegistry.ts crossed the 1000-line new-file cap for the first time purely from combining three independent, already-legitimate provider registrations boarded in the same local merge-train — #10542 (aihorde optional-key image catalog), #10494 (gemini-web image generation), #10594 (freepik/magnific provider rename + validation). 996 on release tip -> 1019 on the train tip. Each PR individually adds a small, additive IMAGE_PROVIDERS registry entry at the existing chokepoint; none crosses the cap alone. Not modularized as part of this train's gate fix (out of scope for a merge reconciliation, not a feature change). Covered by each PR's own focused tests (aihorde-image-catalog/generation, gemini-web image tests, freepik/magnific provider tests).",
|
||||
"_rebaseline_2026_08_20_v3850_merge_train_batch1": "Merge-train batch1 (2026-08-19/20, 30 PRs boarded onto release/v3.8.50): gateways.ts 1255->1268 = PR #10722 (Token Kiosk OpenAI-compatible provider gateway catalog entry, +13 declarative lines, same god-file no-split rationale as prior gateways.ts rebaselines); chatHelpers.ts (uncapped, not previously frozen) new 1017 = PR #10797 (relay/bifrost error normalization, +23/-2, own-PR growth, existing file already near cap from accumulated chokepoint wiring per its own rebaseline history above); chatBodyAdmission.ts (uncapped) new 1005 = pre-existing base-red on the pure release tip (1004>1000 before this train boarded anything, no PR in this batch touches this file) — frozen here at its current size, not authorizing further growth. Owner-authorized rebaseline (2026-08-19 merge-prs session)."
|
||||
"_rebaseline_2026_08_20_v3850_merge_train_batch1": "Merge-train batch1 (2026-08-19/20, 30 PRs boarded onto release/v3.8.50): gateways.ts 1255->1268 = PR #10722 (Token Kiosk OpenAI-compatible provider gateway catalog entry, +13 declarative lines, same god-file no-split rationale as prior gateways.ts rebaselines); chatHelpers.ts (uncapped, not previously frozen) new 1017 = PR #10797 (relay/bifrost error normalization, +23/-2, own-PR growth, existing file already near cap from accumulated chokepoint wiring per its own rebaseline history above); chatBodyAdmission.ts (uncapped) new 1005 = pre-existing base-red on the pure release tip (1004>1000 before this train boarded anything, no PR in this batch touches this file) — frozen here at its current size, not authorizing further growth. Owner-authorized rebaseline (2026-08-19 merge-prs session).",
|
||||
"_rebaseline_2026_08_20_8338_cursor_image_provider": "PR (reimplementation of #8338, @valvesss): imageRegistry.ts 1019->1033 = new cursor IMAGE_PROVIDERS entry (Cursor plan image generation via Agent CLI), +14 lines of declarative provider metadata. Same god-registry no-split rationale as prior imageRegistry/gateways rebaselines.",
|
||||
"_rebaseline_2026_08_20_imageregistry_1034": "imageRegistry.ts 1033->1034: +1 line drift between #10842 (cursor image provider, froze at 1033) and its actual merged state on release (measured 1034) — trivial rebaseline, not a new feature."
|
||||
}
|
||||
@@ -89,7 +89,6 @@ src/
|
||||
├── i18n/ Locale bundles
|
||||
├── instrumentation.ts Next.js instrumentation hook
|
||||
├── instrumentation-node.ts
|
||||
├── server-init.ts Process-level bootstrap (env, DB, jobs, sync)
|
||||
└── proxy.ts Top-level proxy bootstrap helper
|
||||
```
|
||||
|
||||
|
||||
@@ -133,7 +133,6 @@ src/
|
||||
├── types/ # Shared TS type files
|
||||
├── instrumentation.ts # Next.js telemetry hook (browser + edge)
|
||||
├── instrumentation-node.ts # Node-only instrumentation
|
||||
├── server-init.ts # Server bootstrap (DB migrations, jobs, cleanup)
|
||||
└── proxy.ts # HTTP-proxy entry shim
|
||||
```
|
||||
|
||||
|
||||
@@ -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 (342 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 (343 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.345;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: 342 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, a built-in MCP server with 109 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: 343 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, a built-in MCP server with 109 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, 342 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 342 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 109 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, 343 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 343 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 109 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">342 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">343 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 342 providers in</text>
|
||||
<text x="66" y="204" font-size="13.5" fill="#a1a1aa">Auto-fallback across 343 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 342 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: 342 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 343 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: 343 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">342 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">343 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 |
@@ -347,6 +347,8 @@ per-key path take precedence once it is. stdio has no per-caller identity (see
|
||||
| `OMNIROUTE_MCP_SCOPES` | (empty) | Comma-separated allowlist of scopes considered "available" by default (used when caller does not provide its own scopes) |
|
||||
| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | (unset = on) | When set to `0/false/off/no`, disables MCP description compression at registration time |
|
||||
| `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | (unset = on) | Alternate alias for the same toggle as above |
|
||||
| `OMNIROUTE_MCP_FETCH_TIMEOUT_MS` | `10000` | Abort budget for internal management reads (health, resilience, combos, quota, usage) |
|
||||
| `OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS` | `60000` | Abort budget for hops that wait on a provider (`route_request`, `web_search`, `web_fetch`) |
|
||||
| `MCP_TOOL_DENY` | (unset = no filter) | Comma-separated tool names to drop from `tools/list` (tool-cardinality reduction — see below) |
|
||||
| `MCP_TOOL_ALLOW` | (unset = no filter) | Comma-separated tool names to keep exclusively (allow-list mode — see below) |
|
||||
| `DATA_DIR` | `~/.omniroute` | Heartbeat file is written to `${DATA_DIR}/runtime/mcp-heartbeat.json` |
|
||||
|
||||
@@ -239,3 +239,7 @@ Go to Providers → click on the provider → click **Disconnect**.
|
||||
- **[Free Tiers Guide](./FREE-TIERS-GUIDE.md)** — Get free AI with no credit card
|
||||
- **[Troubleshooting](../guides/TROUBLESHOOTING.md)** — Fix common issues
|
||||
- **[Provider Reference](../reference/PROVIDER_REFERENCE.md)** — Full list of 226 providers
|
||||
|
||||
## Cursor images
|
||||
|
||||
Cursor plan images use `IMAGE_PROVIDERS.cursor` (`cursor-agent-image`). See [CURSOR_IMAGE.md](../providers/CURSOR_IMAGE.md).
|
||||
|
||||
@@ -26,6 +26,8 @@ npm install -g omniroute
|
||||
docker run -d --name omniroute -p 20128:20128 diegosouzapw/omniroute:latest
|
||||
```
|
||||
|
||||
`:latest` is the highest **published** stable SemVer. It does **not** track git `main`. Pin `diegosouzapw/omniroute:X.Y.Z` for GitOps. See [Image Tags / Release Channels](../guides/DOCKER_GUIDE.md#release-channels).
|
||||
|
||||
### Option C: From Source
|
||||
|
||||
```bash
|
||||
|
||||
@@ -22,6 +22,7 @@ lastUpdated: 2026-06-28
|
||||
- [Docker Compose with Caddy (HTTPS)](#docker-compose-with-caddy-https-auto-tls)
|
||||
- [Cloudflare Quick Tunnel](#cloudflare-quick-tunnel)
|
||||
- [Image Tags](#image-tags)
|
||||
- [Availability: default SQLite is single-replica](#availability-default-sqlite-is-single-replica)
|
||||
- [Important Notes](#important-notes)
|
||||
|
||||
---
|
||||
@@ -341,13 +342,15 @@ For orchestrators (Kubernetes, Nomad, etc.):
|
||||
|
||||
| Probe | Prefer | Avoid |
|
||||
| --- | --- | --- |
|
||||
| Liveness | TCP on the main port (`PORT`, default `20128`), or soft HTTP `/healthz` | `/api/monitoring/health` as liveness |
|
||||
| Liveness | HTTP `GET /livez`, or TCP on the main port (`PORT`, default `20128`) | `/api/monitoring/health` as liveness |
|
||||
| Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead |
|
||||
| Deep / blackbox | `/api/monitoring/health` | — |
|
||||
|
||||
`/healthz` only reports process lifecycle (`ok` / `starting` / `stopping`). It still
|
||||
runs on the same Node event loop as request handling, so CPU-bound catalog or
|
||||
compression work can delay it — busy ≠ dead. Full probe guidance:
|
||||
`/healthz` reports process lifecycle (`ok` / `starting` / `stopping`). `/livez` is
|
||||
process-alive only (200 whenever the handler can run; it does not wait for
|
||||
readiness). Both still run on the same Node event loop as request handling, so
|
||||
CPU-bound catalog or compression work can delay them — busy ≠ dead. Prefer TCP
|
||||
liveness if HTTP probes time out. Full probe guidance:
|
||||
[Monitoring guide — Kubernetes probe recommendations](../ops/MONITORING_GUIDE.md#kubernetes-probe-recommendations).
|
||||
|
||||
## Docker Compose with Caddy (HTTPS Auto-TLS)
|
||||
@@ -409,8 +412,8 @@ Endpoint tunnel panels (Cloudflare, Tailscale, ngrok) can be shown or hidden fro
|
||||
|
||||
| Image | Tag | Size | Description |
|
||||
| ------------------------ | -------- | ------ | --------------------- |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release |
|
||||
| `diegosouzapw/omniroute` | `3.8.0` | ~250MB | Current version |
|
||||
| `diegosouzapw/omniroute` | `latest` | ~250MB | Highest **published** stable SemVer (not git `main`) |
|
||||
| `diegosouzapw/omniroute` | `3.8.0` | ~250MB | Pin this class of tag for GitOps |
|
||||
|
||||
Multi-platform manifest: `linux/amd64` + `linux/arm64` native (Apple Silicon, AWS Graviton, Raspberry Pi). Docker selects the matching architecture automatically; pass `--platform linux/amd64` if you need to force AMD64 emulation on ARM hosts.
|
||||
|
||||
@@ -421,7 +424,7 @@ OmniRoute publishes separate Docker channels for stable releases, active release
|
||||
| Channel | Source | Mutability | Recommended use |
|
||||
| ------------------------------- | ----------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `:<version>` / `:<version>-web` | Signed/versioned release | Immutable | Production deployments that pin an exact release |
|
||||
| `:latest` / `:latest-web` | Highest stable release | Mutable stable pointer | Production deployments that intentionally follow stable releases |
|
||||
| `:latest` / `:latest-web` | Highest **published** stable SemVer | Mutable stable pointer | Follows stable releases **after** a SemVer publish job — does **not** track `main` or unreleased `release/v*` commits |
|
||||
| `:next` / `:next-web` | Current default `release/v*` branch | Mutable pre-release pointer | Testing fixes that have landed on the active release branch but are not yet in a stable release |
|
||||
| `:main` / `:main-web` | `main` branch | Mutable development pointer | Development and integration testing only |
|
||||
|
||||
@@ -465,6 +468,37 @@ docker compose up -d
|
||||
|
||||
A release-branch build can never move `latest`; only an eligible stable semantic version may promote the stable pointer. The `next` images retain the release image inspection and blocking CRITICAL-vulnerability gate.
|
||||
|
||||
**`latest` is not a currency guarantee for git.** Merged fixes on `main` or on the active `release/v*` branch are **not** in `:latest` until a stable SemVer image is published and the publish job promotes `:latest` (same digest as that SemVer). If `latest` looks frozen while GitHub already shows the fix, pull `:next` to test the release branch or wait for the SemVer tag.
|
||||
|
||||
| You want | Use |
|
||||
| --- | --- |
|
||||
| GitOps / production that must not drift | Pin `:X.Y.Z` (or the image digest) |
|
||||
| Follow published stables and accept a recreate on each release | `:latest` |
|
||||
| Test unreleased `release/v*` commits | `:next` (not production) |
|
||||
| Test `main` | `:main` (not production) |
|
||||
|
||||
## Availability: default SQLite is single-replica
|
||||
|
||||
Stock Docker / Kubernetes OmniRoute is **one Node process + one SQLite writer**. High availability is **not supported** on that topology.
|
||||
|
||||
| Constraint | Consequence |
|
||||
| --- | --- |
|
||||
| Single writer | Do **not** run multiple replicas against the same SQLite file. That corrupts the DB. |
|
||||
| Recreate / restart / HEALTHCHECK kill | **Full outage** of in-flight SSE, dashboard sessions, and in-memory state. Every connected client drops. |
|
||||
| Same event loop as `/healthz` | A busy catalog or compression tick can delay probes; a short timeout then restarts the **only** replica. |
|
||||
|
||||
**Probe matrix** (see also [Kubernetes probe recommendations](../ops/MONITORING_GUIDE.md#kubernetes-probe-recommendations)):
|
||||
|
||||
| Probe | Target | Do not use |
|
||||
| --- | --- | --- |
|
||||
| Liveness | TCP on `PORT` (default `20128`), or soft HTTP `/healthz` | `/api/monitoring/health` |
|
||||
| Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead |
|
||||
| Deep / humans | `/api/monitoring/health` | Automated kubelet liveness |
|
||||
|
||||
**Upgrades:** expect every session to drop. Drain clients if you can; there is no rolling update on default SQLite. Compose `restart: unless-stopped` plus Docker `HEALTHCHECK` will also replace the only process when the container is Unhealthy — same blast radius.
|
||||
|
||||
External Postgres / multi-writer HA is **not** a documented stock path. If you need HA, keep a single replica or run a topology the project has tested and documented separately.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **SQLite WAL Mode:** `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40`.
|
||||
|
||||
@@ -169,7 +169,7 @@ stale data.
|
||||
The sync runs **on by default**:
|
||||
|
||||
- It runs once at server startup and then on a periodic timer
|
||||
(`src/lib/arenaEloSync.ts`, wired from `src/server-init.ts`).
|
||||
(`src/lib/arenaEloSync.ts`, wired from `src/instrumentation-node.ts`).
|
||||
- It is **non-blocking and never fatal** — if the upstream fetch fails, OmniRoute keeps
|
||||
running and the rankings simply show the last good data (or an empty state).
|
||||
|
||||
|
||||
@@ -1,47 +1,159 @@
|
||||
---
|
||||
title: "Management Authentication"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-05
|
||||
lastUpdated: 2026-08-20
|
||||
---
|
||||
|
||||
# Management Authentication
|
||||
|
||||
OmniRoute uses four distinct credential families for management access. This guide
|
||||
distinguishes them by purpose, scope, and locality.
|
||||
OmniRoute has **four credential families** that can authorize management routes.
|
||||
They are not interchangeable. Inference API keys (`sk-…`) do **not** manage the
|
||||
server unless they were explicitly granted `manage` or `admin` scope.
|
||||
|
||||
| Credential | Scope | Locality | Use Case |
|
||||
|-------------------------|--------------------|---------------|-----------------------------------|
|
||||
| Dashboard JWT session | Full management | Localhost | Web dashboard login |
|
||||
| CLI machine-id token | Full management | Per-machine | `omniroute` CLI commands |
|
||||
| Scoped `oma_` token | Configurable scope | External | Automation / CI / API access |
|
||||
| Manage-scope API key | `manage` scope | External | Management API calls |
|
||||
Canonical implementation: `src/lib/api/requireManagementAuth.ts`.
|
||||
|
||||
## Dashboard JWT Session
|
||||
| Credential | Typical form | Created where | Intended use | Management capability |
|
||||
|---|---|---|---|---|
|
||||
| Dashboard session | `auth_token` cookie | Dashboard login | Browser UI | Full dashboard management, subject to CSRF, locality, and always-protected-route rules |
|
||||
| Local CLI machine token | internal / local | CLI bootstrap (`omniroute` on the same machine) | Local CLI | Local management only |
|
||||
| Scoped Access Token | `oma_live_…` | **Settings → Access Tokens** or `omniroute connect` | Remote CLI and management API | Must satisfy the route's required `read`, `write`, or `admin` scope |
|
||||
| Inference API key | `sk-…` (and other API-key prefixes) | **API Manager / API Keys** | `/v1/*` inference | **None** unless the key metadata includes `manage` or `admin` |
|
||||
|
||||
Generated on dashboard login (`/api/auth/login`). Stored in HTTP-only cookie.
|
||||
Valid for the session duration. Cannot be used from external hosts.
|
||||
`oma_` credentials are management/CLI credentials. They are **not** inference API keys.
|
||||
|
||||
## CLI Machine-ID Token
|
||||
If login/API-key auth is disabled for the server, some management routes may
|
||||
accept unauthenticated calls. Local-only and always-protected routes still apply
|
||||
their own rules. Presenting one of these credentials is therefore not universally
|
||||
mandatory, and possessing one is not universally sufficient without the required
|
||||
scope and route locality.
|
||||
|
||||
Created by `omniroute auth login` on first use. Stored in `~/.omniroute/auth.json`.
|
||||
Used by the CLI for all management operations. Tied to the machine identity.
|
||||
Related: [Remote Mode](./REMOTE-MODE.md) (how `oma_live_…` is minted for a remote CLI).
|
||||
|
||||
## Scoped `oma_` Access Token
|
||||
---
|
||||
|
||||
Created via dashboard or CLI with configurable scopes (e.g., `manage`, `read`).
|
||||
Format: `oma_<random-hex>`. Used for programmatic access from external systems.
|
||||
## Scope matrices
|
||||
|
||||
## Manage-Scope API Key
|
||||
These two scope vocabularies are **different**. Do not mix them.
|
||||
|
||||
Standard API key with the `manage` scope enabled. Created in dashboard API Keys page.
|
||||
Used for management API calls from external hosts.
|
||||
### Access Token scopes (`oma_live_…`)
|
||||
|
||||
## Header Examples
|
||||
| Scope | Typical operations |
|
||||
|---|---|
|
||||
| `read` | List/status GETs that the token is allowed to see |
|
||||
| `write` | Mutations (create/update/delete) below admin |
|
||||
| `admin` | Full remote CLI / connect token (password bootstrap defaults here) |
|
||||
|
||||
```
|
||||
Authorization: Bearer oma_abc123def456
|
||||
Authorization: Bearer <standard-api-key-with-manage-scope>
|
||||
Cookie: omniroute_session=<jwt-token>
|
||||
A token with `read` cannot call a `write` route. Runtime message shape:
|
||||
`Access token scope '<have>' is insufficient; '<need>' required.`
|
||||
|
||||
### API-key management scopes
|
||||
|
||||
| Scope | Meaning |
|
||||
|---|---|
|
||||
| (none) | Inference only. Management routes return 403. |
|
||||
| `manage` | Management API (same gate as `requireManagementAuth` API-key branch) |
|
||||
| `admin` | Also satisfies `hasManageScope` (treated as management-capable) |
|
||||
|
||||
Enable `manage` on the key in the API Keys / API Manager UI. Do not reuse a
|
||||
chat client key for automation unless you deliberately granted that scope.
|
||||
|
||||
---
|
||||
|
||||
## How to create and revoke
|
||||
|
||||
### Dashboard session
|
||||
|
||||
1. Open `/login`, sign in with the management password (`INITIAL_PASSWORD` on first boot).
|
||||
2. Cookie `auth_token` is HttpOnly. Browser dashboard uses it automatically.
|
||||
3. Log out via `/api/auth/logout`. There is no long-lived secret to copy.
|
||||
|
||||
### Local CLI machine token
|
||||
|
||||
1. Run `omniroute` on the **same host** as the server (loopback).
|
||||
2. The CLI bootstraps a machine-id token under `~/.omniroute/` (chmod 600).
|
||||
3. This does **not** work from another machine. Use an Access Token for remote CLI.
|
||||
|
||||
### Scoped Access Token (`oma_live_…`)
|
||||
|
||||
1. Dashboard: **Settings → Access Tokens** → create (name + scope). **The secret is shown once.**
|
||||
2. Or CLI: `omniroute connect <host>` (password → token). See [Remote Mode](./REMOTE-MODE.md).
|
||||
3. Header: `Authorization: Bearer oma_live_…`
|
||||
4. Revoke from the same Access Tokens page (or delete the CLI context).
|
||||
5. Server stores only a hash. Treat the plaintext like a password.
|
||||
|
||||
### Manage-scoped API key
|
||||
|
||||
1. Dashboard: **API Manager / API Keys** → create or edit a key → enable `manage` (or `admin`).
|
||||
2. Header: `Authorization: Bearer sk-…` (the key's actual prefix).
|
||||
3. Revoke or strip `manage` in the same UI.
|
||||
4. Least privilege for automation that is not the CLI: prefer a `read` Access Token for GET-only jobs; use `manage` on an API key only when the caller must also speak `/v1` and management.
|
||||
|
||||
---
|
||||
|
||||
## Header format
|
||||
|
||||
```http
|
||||
Authorization: Bearer oma_live_<secret>
|
||||
Authorization: Bearer sk-<secret>
|
||||
Cookie: auth_token=<dashboard-jwt>
|
||||
```
|
||||
|
||||
See `docs/reference/API_REFERENCE.md` for endpoint-specific auth requirements.
|
||||
Do not put management credentials in the URL path or query string. Management
|
||||
auth is header/cookie only.
|
||||
|
||||
---
|
||||
|
||||
## Copy-paste examples
|
||||
|
||||
Read-only (list providers). Use a `read` Access Token:
|
||||
|
||||
```bash
|
||||
curl -sS "$OMNIROUTE_URL/api/providers" \
|
||||
-H "Authorization: Bearer oma_live_<read-token>"
|
||||
```
|
||||
|
||||
Modifying (create a provider connection). Use `write`/`admin` Access Token or a
|
||||
manage-scoped API key:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$OMNIROUTE_URL/api/providers" \
|
||||
-H "Authorization: Bearer oma_live_<write-or-admin-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"provider":"openai","apiKey":"<upstream-key>"}'
|
||||
```
|
||||
|
||||
Inference (not management). Ordinary API key, no `manage` required:
|
||||
|
||||
```bash
|
||||
curl -sS "$OMNIROUTE_URL/v1/models" \
|
||||
-H "Authorization: Bearer sk-<inference-key>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current runtime errors (do not echo secrets)
|
||||
|
||||
| Situation | Typical status | Message (sanitized) |
|
||||
|---|---|---|
|
||||
| No credential | 401 | `Authentication required` |
|
||||
| Invalid/expired `oma_live_…` | 401 | `Invalid or expired access token` |
|
||||
| Valid API key without `manage`/`admin` | 403 | `API key lacks 'manage' scope. Enable it in the API Keys dashboard.` |
|
||||
| Invalid ordinary API key on a management route | 403 | `Invalid management token` |
|
||||
| Access Token scope too low | 403 | `Access token scope '<have>' is insufficient; '<need>' required.` |
|
||||
|
||||
"Invalid management token" means the bearer was **not** accepted as a management
|
||||
credential. It does **not** tell you which family to mint. Use the table above:
|
||||
inference keys need `manage` scope; remote CLI needs `oma_live_…`; the dashboard
|
||||
uses the session cookie.
|
||||
|
||||
---
|
||||
|
||||
## Recommended least-privilege choice
|
||||
|
||||
| Caller | Use |
|
||||
|---|---|
|
||||
| Browser | Dashboard session |
|
||||
| CLI on the server host | Machine token |
|
||||
| CLI on a laptop talking to a remote server | `oma_live_…` from `omniroute connect` |
|
||||
| CI / scripts (management only) | `oma_live_…` with the smallest scope that works |
|
||||
| CI that must call both `/v1` and `/api` | API key with `manage` **or** two credentials |
|
||||
|
||||
@@ -2069,6 +2069,39 @@ paths:
|
||||
description: Created combo
|
||||
|
||||
/api/combos/{id}:
|
||||
get:
|
||||
tags: [Combos]
|
||||
summary: Get combo by ID
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ResourceId"
|
||||
responses:
|
||||
"200":
|
||||
description: Combo details
|
||||
"404":
|
||||
description: Combo not found
|
||||
put:
|
||||
tags: [Combos]
|
||||
summary: Update combo
|
||||
description: >-
|
||||
Partial update: the body is merged onto the stored combo, so a field left out keeps
|
||||
its current value. An array that IS sent replaces the stored one outright.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ResourceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
description: Updated combo
|
||||
"400":
|
||||
description: Invalid body, or the resulting combo fails validation
|
||||
"404":
|
||||
description: Combo not found
|
||||
"409":
|
||||
description: Name already taken, or the combo is quota-share managed
|
||||
patch:
|
||||
tags: [Combos]
|
||||
summary: Update combo
|
||||
@@ -7329,12 +7362,18 @@ components:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: API key obtained from the OmniRoute dashboard
|
||||
description: >
|
||||
Two bearer families are accepted. Inference API keys (typically `sk-…`)
|
||||
authorize `/v1/*`. Management routes also accept `oma_live_…` Access Tokens
|
||||
(Settings → Access Tokens / `omniroute connect`) and API keys whose metadata
|
||||
includes `manage` or `admin` scope. See docs/guides/MANAGEMENT-AUTH.md.
|
||||
Bearer credentials are accepted on management routes that use this scheme;
|
||||
they are not rejected solely for being Bearer.
|
||||
ManagementSessionAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: auth_token
|
||||
description: Dashboard management session cookie for protected management routes
|
||||
description: Dashboard management session cookie (auth_token) for protected management routes. Distinct from Bearer Access Tokens and API keys. See docs/guides/MANAGEMENT-AUTH.md.
|
||||
|
||||
parameters:
|
||||
ResourceId:
|
||||
|
||||
@@ -453,26 +453,47 @@ Run monthly during low-traffic windows. (WAL mode reduces the need, but doesn't
|
||||
|
||||
`src/lib/db/healthCheck.ts` provides **DB-level health diagnostics**:
|
||||
|
||||
````bash
|
||||
GET /api/db/health
|
||||
Both verbs require authentication (`401` otherwise). `GET` diagnoses only; `POST` runs the
|
||||
same check with `autoRepair` enabled.
|
||||
|
||||
Returns:
|
||||
```bash
|
||||
GET /api/db/health # diagnose
|
||||
POST /api/db/health # diagnose + repair
|
||||
```
|
||||
|
||||
The response is the `DbHealthCheckResult` produced by `runDbHealthCheck()`
|
||||
(`src/lib/db/healthCheck.ts`):
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"checks": {
|
||||
"writable": { "status": "pass" },
|
||||
"integrity": { "status": "pass", "result": "ok" },
|
||||
"foreign_keys": { "status": "pass", "violations": 0 },
|
||||
"orphaned_artifacts": { "status": "warn", "count": 12 },
|
||||
"table_sizes": {
|
||||
"usage_history": { "rows": 12345, "size_mb": 12.3 },
|
||||
"call_logs": { "rows": 567, "size_mb": 2.1 }
|
||||
"isHealthy": false,
|
||||
"issues": [
|
||||
{
|
||||
"type": "broken_reference",
|
||||
"table": "domain_budgets",
|
||||
"description": "Domain budgets referenced API keys that no longer exist.",
|
||||
"count": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"repairedCount": 0,
|
||||
"backupCreated": false,
|
||||
"autoRepair": false,
|
||||
"checkedAt": "2026-08-18T09:00:00.000Z",
|
||||
"driver": { "name": "better-sqlite3", "degraded": false }
|
||||
}
|
||||
````
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `isHealthy` | `true` when `issues` is empty. `driver` never influences it. |
|
||||
| `issues[].type` | One of `integrity_check_failed`, `broken_reference`, `stale_snapshot`, `invalid_state`. |
|
||||
| `repairedCount` | Rows repaired during this run; always `0` when `autoRepair` is false. |
|
||||
| `backupCreated` | Whether a backup was taken before repairing. |
|
||||
| `checkedAt` | ISO timestamp shared by the run and by any repair note it writes. |
|
||||
| `driver.name` | SQLite driver serving the checked database. |
|
||||
| `driver.degraded` | `true` when writes are not durably backed by the database file — the `sql.js` WASM fallback (whole-file persistence) or an in-memory database. |
|
||||
|
||||
The same payload is returned by the `omniroute_db_health_check` MCP tool.
|
||||
|
||||
Run `PRAGMA integrity_check` to detect corruption:
|
||||
|
||||
|
||||
@@ -157,13 +157,13 @@ Response:
|
||||
|
||||
### Kubernetes probe recommendations
|
||||
|
||||
OmniRoute is a **single Node process** (one event loop). Stock Docker `HEALTHCHECK` targets `/api/monitoring/health` — that is **too heavy** for kubelet liveness intervals.
|
||||
OmniRoute is a **single Node process** (one event loop). Stock Docker `HEALTHCHECK` targets lightweight `/healthz`. `/api/monitoring/health` is **too heavy** for kubelet liveness intervals.
|
||||
|
||||
| Probe | Recommended target | Notes |
|
||||
| --- | --- | --- |
|
||||
| **Startup** | HTTP `GET /healthz` with a long `failureThreshold` (or large `startPeriod`) | Cold start + SQLite migration can exceed a few seconds |
|
||||
| **Readiness** | HTTP `GET /healthz` | Remove endpoints while starting/stopping; still flaps if the loop is CPU-blocked |
|
||||
| **Liveness** | **TCP** on the main service port (`PORT`, default `20128`), **or** HTTP `/healthz` with soft thresholds | Do **not** kill the pod on short event-loop stalls; busy ≠ dead |
|
||||
| **Readiness** | HTTP `GET /healthz` | Lifecycle `ok` / `starting` / `stopping` (200 vs 503). Still flaps if the loop is CPU-blocked. A **200 in multiple seconds is not healthy** (#10303) — it means the event loop was starved before the 3-byte handler ran |
|
||||
| **Liveness** | HTTP `GET /livez`, **or TCP** on the main service port (`PORT`, default `20128`) | `/livez` is process-alive only (always 200 if the handler runs). It still shares the event loop — busy ≠ dead, and it does not detect event-loop starvation (#10303) any better than TCP does. Prefer **TCP** if HTTP probes time out under catalog/compression load; do **not** kill the pod on short event-loop stalls either way |
|
||||
| **Deep health** | `GET /api/monitoring/health` from an external checker | Not for kubelet `livenessProbe` / tight `readinessProbe` |
|
||||
|
||||
Example shape (adjust thresholds to your cold-start and compression load):
|
||||
@@ -186,17 +186,27 @@ readinessProbe:
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: http
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 6
|
||||
# Under event-loop stall HTTP /livez can still time out. TCP is the
|
||||
# conservative alternative:
|
||||
# tcpSocket:
|
||||
# port: http
|
||||
```
|
||||
|
||||
**Do not** point kubelet **liveness** at `/api/monitoring/health`. That path does real DB/monitoring work and will false-positive under load.
|
||||
|
||||
Related: [#10052](https://github.com/diegosouzapw/OmniRoute/issues/10052) (probes while the event loop is busy), [#9685](https://github.com/diegosouzapw/OmniRoute/issues/9685) / [#10055](https://github.com/diegosouzapw/OmniRoute/pull/10055) (catalog pricing hog), [#10117](https://github.com/diegosouzapw/OmniRoute/issues/10117) (compression token-count hog).
|
||||
|
||||
|
||||
### Optional request-path work (memory, skills, token refresh)
|
||||
|
||||
Memory extraction, skills injection, and OAuth token refresh share the **main Node event loop** with `/healthz`. They are dashboard-toggle features (`memoryEnabled`, `skillsEnabled`), not a worker pool. See [Environment — event-loop cost](../reference/ENVIRONMENT.md#event-loop-cost-of-memory-skills-and-token-refresh-10349).
|
||||
|
||||
### Provider Health
|
||||
|
||||
> **No REST endpoint.** Provider health data is available via the MCP tool `observability_snapshot` or the dashboard `/dashboard/providers` page.
|
||||
|
||||
@@ -66,6 +66,15 @@ as the default reflex (minutes, reversible); `npm unpublish` only inside the 72h
|
||||
window and never as the first move. Docker: never rewrite a version tag — rollback is
|
||||
repointing `latest` to the last good digest.
|
||||
|
||||
**Docker Hub `latest` (required on every stable SemVer publish):** the
|
||||
`docker-publish` workflow must tag **both** `X.Y.Z` and, when
|
||||
`should-promote-latest.sh` agrees this is the highest stable SemVer, `:latest`
|
||||
with the **same digest**. After the job: Hub `latest` digest equals the new
|
||||
SemVer digest and `last_updated` moved. Do not leave `:latest` on an older
|
||||
build while release notes talk about fixes that only exist on git. Compose
|
||||
quickstarts use `:latest`; GitOps should keep pinning `X.Y.Z`. See
|
||||
[Docker release channels](../guides/DOCKER_GUIDE.md#release-channels) and #10317.
|
||||
|
||||
## Hotfix Fast-Lane (label `hotfix`)
|
||||
|
||||
A PR labeled `hotfix` skips the heavy CI matrix (9-shard E2E, coverage ratchet,
|
||||
|
||||
@@ -80,3 +80,17 @@ Implementation:
|
||||
- `bin/cli/runtime/index.mjs` — startup orchestrator (`warmUpRuntimes()`)
|
||||
- `scripts/postinstall.mjs` — npm post-install hook (non-fatal warm-up)
|
||||
- `src/lib/db/core.ts` — `ensureDbInitialized()` / `getDriverInfo()` exports
|
||||
|
||||
## Single-writer topology (HA unsupported)
|
||||
|
||||
The driver fallback chain above still runs in **one process**. Default SQLite
|
||||
OmniRoute is a **single writer**:
|
||||
|
||||
- Do not attach two OmniRoute replicas to the same `storage.sqlite` file.
|
||||
- A container restart, Recreate deploy, OOM kill, or HEALTHCHECK restart drops
|
||||
every in-flight SSE session. There is no session drain on the stock path.
|
||||
- Orchestrator liveness that treats a slow `/healthz` as dead will kill the only
|
||||
replica. Prefer TCP liveness + HTTP `/healthz` readiness. See
|
||||
[Docker Guide — availability](../guides/DOCKER_GUIDE.md#availability-default-sqlite-is-single-replica)
|
||||
and [Kubernetes probe recommendations](./MONITORING_GUIDE.md#kubernetes-probe-recommendations).
|
||||
|
||||
|
||||
75
docs/providers/CURSOR_IMAGE.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "Cursor Image Generation"
|
||||
version: 3.8.49
|
||||
lastUpdated: 2026-07-23
|
||||
---
|
||||
|
||||
# Cursor Image Generation
|
||||
|
||||
OmniRoute exposes Cursor plan **image generation** on `POST /v1/images/generations` through the same provider id as chat: `cursor` (alias `cu`).
|
||||
|
||||
| Field | Value |
|
||||
|-------|--------|
|
||||
| `IMAGE_PROVIDERS` id | `cursor` |
|
||||
| Format | `cursor-agent-image` |
|
||||
| Auth | Same OAuth / API-key connection as chat (`provider_connections.provider = "cursor"`) |
|
||||
| Models | `cursor/auto`, `cursor/composer-2`, `cursor/composer-2.5` |
|
||||
|
||||
## Why the Agent CLI
|
||||
|
||||
Cursor chat in OmniRoute uses `agent.v1.AgentService/Run` (protobuf). That path **rejects** built-in client tools (shell, write, …). Image generation is a Cursor-native tool executed by the **`agent` CLI** against the seat. The image handler therefore spawns `agent` with a locked prompt and a per-request temp workspace (same shape as community seat bridges), then returns OpenAI-compatible `b64_json`.
|
||||
|
||||
## Access restriction (Hard Rules #15 + #17)
|
||||
|
||||
This is the only `IMAGE_PROVIDERS` format that spawns a child process (the `agent`
|
||||
binary). Because `POST /v1/images/generations` is shared by ~40 other, non-spawning
|
||||
image providers that remote callers legitimately use, the whole route is **not**
|
||||
classified `LOCAL_ONLY` — instead `handleCursorAgentImageGeneration` enforces its own
|
||||
gate using the trusted `AUTHZ_HEADER_PEER_LOCALITY` verdict the authz pipeline stamps
|
||||
on every request (from the real TCP peer, never the spoofable `Host` header): only
|
||||
`loopback` and `lan` callers may reach the spawn; everything else (including a leaked
|
||||
API key replayed over a public tunnel) gets `403` before any credential lookup or
|
||||
process spawn happens. See `src/server/authz/policies/management.ts` for the same
|
||||
policy applied to the rest of the `LOCAL_ONLY` tier.
|
||||
|
||||
## Concurrency gate is module-level (single-instance limitation)
|
||||
|
||||
`CURSOR_IMG_MAX_CONCURRENT` is enforced by an in-memory counter/queue scoped to the
|
||||
Node module instance (`open-sse/handlers/imageGeneration/providers/cursorAgentImage.ts`).
|
||||
It correctly limits concurrent `agent` spawns within one OmniRoute process, but does
|
||||
**not** coordinate across multiple processes/instances sharing the same Cursor seat
|
||||
(e.g. a multi-replica deployment) — each instance enforces its own independent limit.
|
||||
For a single-instance deployment (the default) this is exact; horizontally scaled
|
||||
deployments should keep `CURSOR_IMG_MAX_CONCURRENT` conservative per instance or route
|
||||
Cursor image traffic to a single instance.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. A connected Cursor account in the dashboard (OAuth or `crsr_…` API key).
|
||||
2. The Cursor Agent binary available to the OmniRoute process:
|
||||
- env `CURSOR_AGENT_BIN=/path/to/agent`, or
|
||||
- `~/.local/bin/agent`, or
|
||||
- `providerSpecificData.agentBin` on the Cursor connection.
|
||||
|
||||
Optional tuning:
|
||||
|
||||
| Env | Default | Meaning |
|
||||
|-----|---------|---------|
|
||||
| `CURSOR_IMG_TIMEOUT_MS` | `210000` | Per-image wall clock |
|
||||
| `CURSOR_IMG_MAX_CONCURRENT` | `2` | Shared-seat concurrency gate |
|
||||
| `CURSOR_IMG_MODEL` | (request model / `auto`) | Override CLI `--model` |
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
curl -sS https://<host>/v1/images/generations \
|
||||
-H "Authorization: Bearer <omni-api-key>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"cursor/auto","prompt":"a lantern in fog","size":"1024x1024"}'
|
||||
```
|
||||
|
||||
Generation typically takes 1–2 minutes. Prefer an internal network path; edge proxies with ~100s timeouts will fail.
|
||||
|
||||
## LiteLLM
|
||||
|
||||
Register an image model with `mode: image_generation`, `api_base: http://omniroute:20128/v1`, and `model: openai/cursor/auto` (or bare `cursor/auto` depending on your LiteLLM version).
|
||||
@@ -103,7 +103,7 @@ The manual import endpoint can also be called directly:
|
||||
```
|
||||
POST /api/providers/zed/manual-import
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <management-token>
|
||||
Authorization: Bearer <oma_live_or_manage_scoped_api_key>
|
||||
|
||||
{
|
||||
"provider": "openai",
|
||||
|
||||
@@ -703,6 +703,10 @@ X-OmniRoute-No-Cache: true
|
||||
|
||||
## Dashboard & Management
|
||||
|
||||
Management routes (`/api/*` except public auth/login) are **not** authorized by
|
||||
ordinary inference API keys. Credential families, scopes, and curl examples:
|
||||
[Management Authentication](../guides/MANAGEMENT-AUTH.md).
|
||||
|
||||
### Authentication
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
@@ -1668,9 +1672,14 @@ See [Security > Guardrails](../security/GUARDRAILS.md) for full details.
|
||||
|
||||
## Authentication
|
||||
|
||||
See [Management Authentication](../guides/MANAGEMENT-AUTH.md) for the four
|
||||
credential families (dashboard session, local CLI token, `oma_live_…` Access
|
||||
Token, manage-scoped API key) and how they differ from inference keys.
|
||||
|
||||
- Dashboard routes (`/dashboard/*`) use `auth_token` cookie
|
||||
- Login uses saved password hash; fallback to `INITIAL_PASSWORD`
|
||||
- `requireLogin` toggleable via `/api/settings/require-login`
|
||||
- `/v1/*` routes optionally require Bearer API key when `REQUIRE_API_KEY=true`
|
||||
- "management token" / "management-scoped API key" in this reference means one of the families in that guide — not an undefined extra secret type
|
||||
|
||||
> **Breaking change (v3.8.0)** — `/api/v1/agents/tasks/*` and the cooldown management endpoints now require **management auth** (dashboard `auth_token` cookie or a management-scoped API key). Clients that previously called these routes unauthenticated will receive `401 Unauthorized`. See commit `588a0333` (`fix(auth): require management auth for agent and cooldown APIs`).
|
||||
|
||||
@@ -91,7 +91,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
|
||||
| `OMNIROUTE_DATA_DIR` | _(unset)_ | `open-sse/executors/promptql/threadSticky.ts` | **Fallback alias** for `DATA_DIR`, checked only when `DATA_DIR` is unset. Used to locate the PromptQL executor's on-disk thread-sticky session cache (`<dir>/promptql-thread-sessions.json`); if neither var is set, the cache stays in-memory only (not persisted across restarts). |
|
||||
| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. |
|
||||
| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. |
|
||||
| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips the automatic database backup that runs before migrations on every startup. |
|
||||
| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips automatic + pre-write SQLite file backups (startup, models.dev pricing save/clear, settings writes). Manual and pre-restore backups still run. Non-manual backups are also **throttled to at most once per 60 minutes** so hourly models.dev sync does not copy the whole DB on every pricing write. Dashboard **Settings → Storage** can disable auto-backup independently. |
|
||||
| `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** for `STORAGE_ENCRYPTION_KEY`. Accepted as a fallback when the primary variable is absent. |
|
||||
| `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** (Base64-encoded form) accepted as a fallback. Decoded automatically before use. |
|
||||
| `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(unset)_ | `src/lib/db/core.ts` | Override the periodic SQLite healthcheck interval (ms). When unset, defaults are derived from `NODE_ENV`. |
|
||||
@@ -153,6 +153,8 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
|
||||
| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. |
|
||||
| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows, when running into native binding / bundler-compat incompatibilities, **or on RAM-constrained machines** — Turbopack production builds on this Next.js version line (16.2.x) are known upstream to peak far higher in memory than webpack on large module graphs (Next 16.3's Turbopack memory-eviction fix is not yet stable); webpack fallback peaks much lower. See #6409. |
|
||||
| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. |
|
||||
| `NOTIFY_SOCKET` | _(unset)_ | systemd (sd_notify protocol) | Set by systemd when the process runs under a service unit with sd_notify integration; OmniRoute reads it (see `OMNIROUTE_DISABLE_SD_NOTIFY`) to send READY/WATCHDOG notifications. Never set by the user. |
|
||||
| `OMNIROUTE_DISABLE_SD_NOTIFY` | _(unset)_ | `scripts/dev/systemd-notify.mjs` | Set to `1` to disable systemd sd_notify (Type=notify / WatchdogSec=) even when running under a systemd unit. The notifier is a no-op outside systemd regardless. |
|
||||
| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). |
|
||||
| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. |
|
||||
| `OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK` | `false` | `src/lib/credentialHealth/scheduler.ts` | Set to `1` or `true` to disable background periodic testing of provider connections. Search providers (SEARCH_VALIDATOR_CONFIGS in `src/lib/providers/validation/searchProviders.ts`, e.g. `tavily-search`) are always excluded from the sweep — their "validation" is a real billed upstream query, so they are never health-checked on a timer (#9970). |
|
||||
@@ -347,6 +349,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con
|
||||
| `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. |
|
||||
| `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. |
|
||||
| `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). |
|
||||
| `OMNIROUTE_PROXY_ECHO_URL` | _(unset)_ | `src/lib/proxyEchoTarget.ts` | Pins the echo-IP target used by proxy egress probes to a single URL. Unset, the probe tries `api64.ipify.org` then `api4.ipify.org` so IPv4-only tunnels are not reported dead (#9694). |
|
||||
| `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. |
|
||||
| `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher. Long-lived SSE streams such as Codex `/v1/responses` need more than one connection when several requests share the same account-level proxy. Values above `256` are capped. |
|
||||
| `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 handshake (connect) timeout in ms. Raise it when a single residential gateway host is hit by high concurrency (e.g. 100 simultaneous requests) — the real handshake can exceed 10s under a saturated pool even though the proxy is reachable, which otherwise surfaces as a false `[Proxy Fast-Fail] Proxy unreachable`. Capped at `120000`. |
|
||||
@@ -502,7 +505,7 @@ detection above).
|
||||
| `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | `false` | `open-sse/mcp-server/descriptionCompressor.ts` | Compress MCP tool descriptions before serializing the manifest. Enable values: `1`, `true`, `on`. |
|
||||
| `OMNIROUTE_MCP_DESCRIPTION_COMPRESSION` | `rtk` | `open-sse/mcp-server/descriptionCompressor.ts` | Compression algorithm/profile. Disable values: `0`, `false`, `off`. |
|
||||
| `MODEL_SYNC_INTERVAL_HOURS` | `24` | `src/shared/services/modelSyncScheduler.ts` | Model catalog sync interval in hours. |
|
||||
| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/server-init.ts` | Provider rate-limit and quota polling interval. |
|
||||
| `PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES` | `70` | `src/lib/usage/providerLimits.ts` | Provider rate-limit and quota polling interval. |
|
||||
| `PROVIDER_LIMITS_SYNC_SPACING_MS` | `1500` | `src/lib/usage/providerLimits.ts` | Gap (ms) between consecutive OAuth quota fetches in a bulk sync; OAuth connections are fetched one at a time to avoid bursting an upstream. `0` opts out (concurrent). |
|
||||
| `OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS` | `250` | `open-sse/services/quotaFetchThrottle.ts` | Min interval (ms) between consecutive upstream quota fetches on the per-request preflight/monitor path; spaces concurrent network calls so many accounts on one IP don't burst the upstream. Wired into the Codex (`/wham/usage`), DeepSeek, Bailian (both fetch sites), OpenCode, and Crof quota fetchers (#6009, #6911). The generic `usage.ts::getUsageForProvider` dispatch path (github/glm/minimax/nanogpt/xai/etc.) is not yet covered — tracked separately. Cache hits unaffected. `0` disables; clamped `0..5000`. |
|
||||
| `PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS` | `5000` | `src/lib/usage/providerLimits.ts` | Delay (ms) before refreshing provider limits after a real usage event, giving the upstream quota API time to register consumption. |
|
||||
@@ -841,7 +844,7 @@ The logging system writes to both stdout and rotated log files. All configuratio
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `OMNIROUTE_MEMORY_MB` | _auto_ | Runtime V8 heap limit (MB). When unset, calibrated dynamically (~35% of system RAM, clamped to `[512, 4096]`); `512` is only the floor when total memory can't be read. Set explicitly to override. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. |
|
||||
| `OMNIROUTE_MEMORY_MB` | _auto_ | **Recommended** Docker/standalone V8 heap limit (MB). When unset, calibrated dynamically (~35% of system RAM, clamped to `[512, 4096]`); `512` is only the floor when total memory can't be read. On `run-standalone.mjs` (Docker CMD), an **explicit** value is appended as `--max-old-space-size` and **wins** over a conflicting NODE_OPTIONS heap flag (V8 last-flag). `omniroute serve` still prefers an existing NODE_OPTIONS heap (#5238). Do not set both to different numbers — the process logs a warn naming both values and the winner. |
|
||||
| `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. |
|
||||
| `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. |
|
||||
| `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. |
|
||||
@@ -860,6 +863,19 @@ The logging system writes to both stdout and rotated log files. All configuratio
|
||||
|
||||
### Memory Engine (plan 21)
|
||||
|
||||
### Event-loop cost of memory, skills, and token refresh (#10349)
|
||||
|
||||
OmniRoute is a **single Node process**. Memory extraction/retrieval, skills injection, and provider token refresh run on that **same event loop** as `GET /healthz` and the dashboard. They are not a worker thread.
|
||||
|
||||
| Work | Code | Default | Operator control |
|
||||
| --- | --- | --- | --- |
|
||||
| Memory extraction / retrieval | `src/lib/memory/` | Dashboard **memoryEnabled** (default on) | Turn off **Settings → Memory**. There is no separate env kill switch beyond disabling the feature in settings. |
|
||||
| Skills injection | `src/lib/skills/injection.ts` | Dashboard **skillsEnabled** (default on) | Turn off **Settings → Memory/Skills** (`skillsEnabled`). Sandbox knobs below only bound execution after injection is already on. |
|
||||
| Token refresh | `src/sse/services/tokenRefresh.ts` | On for connected OAuth/web providers | Disconnect the provider or let tokens stay valid; there is no `TOKEN_REFRESH=0` env today. |
|
||||
|
||||
If `/healthz` is slow on a quiet box, disable memory + skills first, then check catalog/compression load (#10303, #9685). These features yield at `await` points but still compete for the one thread.
|
||||
|
||||
|
||||
Embedding layer, vector store and reranking knobs for the persistent memory subsystem (`src/lib/memory/`).
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -968,7 +984,7 @@ desktop install.
|
||||
|
||||
| Variable | Default | Source File | Description |
|
||||
| ----------------------------------- | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MODELS_DEV_SYNC_ENABLED` | _(unset)_ | `src/lib/modelsDevSync.ts` | Hard override for models.dev pricing sync. Unset = honor Settings > AI (`modelsDevSyncEnabled`). `0`/`false`/`off`/`no` **wins over the DB** and skips both periodic sync and `getModelsDevPricing()` SQL/JSON scans (recovery when the dashboard is wedged on the same event loop). `1`/`true`/`on`/`yes` forces sync on. |
|
||||
| `MODELS_DEV_SYNC_ENABLED` | _(unset)_ | `src/lib/modelsDevSync.ts` | Hard override for models.dev pricing sync. Unset = honor Settings > AI (`modelsDevSyncEnabled`). `0`/`false`/`off`/`no` **wins over the DB** and skips both periodic sync and `getModelsDevPricing()` SQL/JSON scans (recovery when the dashboard is wedged on the same event loop). `1`/`true`/`on`/`yes` forces sync on. Pricing save/clear still call `backupDbFile("pre-write")`, which is no-op under the 60-minute throttle or `DISABLE_SQLITE_AUTO_BACKUP`. |
|
||||
| `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. |
|
||||
| `CONTEXT_WINDOW_RECONCILE_INTERVAL` | `86400` (24h) | `src/lib/contextWindowResolver.ts` | Interval (seconds) for the self-correcting context-window reconciler (5004): pins provider-declared windows from `/models` discovery as `auto:discovery` overrides when they diverge from the catalog. Set to `0` to disable. Reuses already-synced data (no new fetch); never overwrites `manual` overrides. |
|
||||
|
||||
@@ -1030,7 +1046,10 @@ Anthropic-compatible provider instead.
|
||||
| `PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS` | `2000` | `src/lib/proxyHealth.ts` | Cache TTL for failed proxy health probes. Keep this shorter than `PROXY_HEALTH_CACHE_TTL_MS` so transient proxy timeouts under high concurrency retry quickly without disabling fast-fail for truly dead proxies. |
|
||||
| `PROXY_HEALTH_ENABLED` | `true` | `src/lib/proxyHealth/scheduler.ts` | Set `false` to disable the background proxy health scheduler that periodically probes registered proxies. |
|
||||
| `PROXY_HEALTH_INTERVAL_MS` | `600000` | `src/lib/proxyHealth/scheduler.ts` | Background health-scheduler sweep interval in ms (minimum `60000`). |
|
||||
| `PROXY_HEALTH_TEST_URL` | `https://httpbin.org/ip` | `src/lib/proxyHealth/scheduler.ts` | Reachability probe target used by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Point it at an internal/self-hosted URL to avoid the public default. |
|
||||
| `PROXY_HEALTH_TEST_URL` | `https://httpbin.org/ip` | `src/lib/proxyHealth/probeTarget.ts` | Reachability probe target used by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Point it at an internal/self-hosted URL to avoid the public default. |
|
||||
| `PROXY_HEALTH_TEST_CONCURRENCY` | `10` | `src/lib/proxyHealth/probeTarget.ts` | Probes started at once per batch, shared by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Floored at 1 and capped at 50. |
|
||||
| `PROXY_HEALTH_TEST_STAGGER_MS` | `100` | `src/lib/proxyHealth/probeTarget.ts` | Delay in ms between two probe departures inside a batch. Without it the whole batch leaves at the same moment and a shared egress IP can trip a rate-limited target. Set to `0` to disable the spacing; capped at 5000. |
|
||||
| `PROXY_HEALTH_USE_PROVIDER_TARGET` | `true` | `src/lib/proxyHealth/providerProbeTarget.ts` | Set "false" to stop probing the real host of a proxy's assigned provider (`GET /models`, no API key) and always use `PROXY_HEALTH_TEST_URL` instead. |
|
||||
| `PROXY_HEALTH_AUTO_DEACTIVATE` | `false` | `src/lib/proxyHealth/statusPolicy.ts` | When `false` (default), automated reachability probes (the scheduler + the `/api/settings/proxies/auto-test` "Test All" button) are **read-only** and never write a proxy's status — only the operator sets active/inactive, so a flaky probe can't strand an assigned proxy (#6246). Set `true` to restore the legacy test-and-set behaviour. |
|
||||
| `PROXY_AUTO_REMOVE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler auto-remove proxies after repeated consecutive failures. |
|
||||
| `PROXY_AUTO_REMOVE_AFTER` | `3` | `src/lib/proxyHealth/scheduler.ts` | Consecutive failures before the scheduler auto-removes a proxy (when `PROXY_AUTO_REMOVE=true`). |
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
title: "Provider Reference"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-19
|
||||
lastUpdated: 2026-08-20
|
||||
---
|
||||
|
||||
# Provider Reference
|
||||
|
||||
> **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand.
|
||||
> Regenerate with: `npm run gen:provider-reference`
|
||||
> **Last generated:** 2026-08-19
|
||||
> **Last generated:** 2026-08-20
|
||||
|
||||
Total providers: **342**. See category breakdown below.
|
||||
Total providers: **343**. See category breakdown below.
|
||||
|
||||
## Categories
|
||||
|
||||
@@ -62,8 +62,8 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
| `clinepass` | `cp` | ClinePass | OAuth | [link](https://cline.bot/cline-pass) | ClinePass is Cline's $9.99/mo subscription bundling 10 open coding models. Sign in with your Cline account (same login as the Cline CLI/IDE), or paste a direct ClinePass API key (app.cline.bot → Settings → API Keys). A ClinePass subscription unlocks the cline-pass/* models. Reuses the Cline WorkOS OAuth flow. |
|
||||
| `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. |
|
||||
| `codex` | `cx` | OpenAI Codex | OAuth | — | — |
|
||||
| `cursor` | `cu` | Cursor IDE | OAuth | — | — |
|
||||
| `devin-cli` | `dv` | Devin CLI | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai |
|
||||
| `cursor` | `cu` | Cursor IDE | OAuth, image | — | Image via Agent CLI (`CURSOR_AGENT_BIN`); same seat as chat |
|
||||
| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai |
|
||||
| `devin-desktop` | — | Devin Desktop | OAuth | [link](https://devin.ai) | Paste an existing Devin API key from an authenticated Devin session. Key export availability and steps vary by Devin version and account. |
|
||||
| `ghe-copilot` | `ghe-copilot` | GitHub Enterprise Copilot | OAuth | — | Enter your GHE instance URL (e.g., https://ghe.company.com) in provider settings, then authenticate via device flow. |
|
||||
| `github` | `gh` | GitHub Copilot | OAuth | — | — |
|
||||
@@ -120,7 +120,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
| `zai-web` | `zw` | Z.ai Web | Web cookie | [link](https://chat.z.ai) | Copy the "token" value from chat.z.ai → DevTools → Application → Local Storage. Do not copy cookies; OmniRoute handles the per-request CAPTCHA through its browser transport. | — |
|
||||
| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — |
|
||||
|
||||
## API Key Providers (paid / paid-with-free-credits) (229)
|
||||
## API Key Providers (paid / paid-with-free-credits) (230)
|
||||
|
||||
| ID | Alias | Name | Tags | Website | Notes |
|
||||
|----|-------|------|------|---------|-------|
|
||||
@@ -323,9 +323,9 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. |
|
||||
| `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys |
|
||||
| `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | — |
|
||||
| `token-kiosk` | `tk` | Token Kiosk | API key | [link](https://agent-router.gaib.ai) | Use your Token Kiosk API key in Authorization: Bearer <key>. Fully OpenAI-compatible gateway. API base URL: https://agent-router.gaib.ai/v1. |
|
||||
| `tokenreply` | `tokenreply` | TokenReply | API key, aggregator | [link](https://www.tokenreply.com) | Free-tagged models have model- and campaign-specific daily limits; no fixed global free quota is published. |
|
||||
| `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer *** Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. |
|
||||
| `token-kiosk` | `tk` | Token Kiosk | API key | [link](https://agent-router.gaib.ai) | Use your Token Kiosk API key in Authorization: Bearer *** Fully OpenAI-compatible. API base URL: https://agent-router.gaib.ai/v1. |
|
||||
| `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer <key>. Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. |
|
||||
| `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — |
|
||||
| `typhoon` | `typhoon` | Typhoon | API key | [link](https://docs.opentyphoon.ai) | Free API key with a 5 req/s and 200 req/m rate limit. |
|
||||
| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) |
|
||||
|
||||
@@ -38,7 +38,7 @@ this service has been disabled in this account (Antigravity)
|
||||
> copy is `ACCOUNT_DEACTIVATED_SIGNALS` in `open-sse/services/accountFallback.ts`;
|
||||
> treat the block above as a snapshot.
|
||||
|
||||
Two adjacent, **separate** signal tables live in the same file and are *not* part
|
||||
Two adjacent, **separate** signal tables live in the same file and are _not_ part
|
||||
of banned-keyword detection:
|
||||
|
||||
- `CREDITS_EXHAUSTED_SIGNALS` — billing/quota depleted (`insufficient_quota`,
|
||||
@@ -70,7 +70,7 @@ upstream error response
|
||||
narrower **`deactivated`** label (`isActive=false` when the connection has no
|
||||
spare API keys) is written by the inline `chatCore.ts` path on **HTTP 401 / 403**
|
||||
(classified via `classifyProviderError` → `ACCOUNT_DEACTIVATED`). Note the
|
||||
`markAccountUnavailable()` path writes a *different* terminal status —
|
||||
`markAccountUnavailable()` path writes a _different_ terminal status —
|
||||
**`expired`** — for the same `ACCOUNT_DEACTIVATED` signal (via
|
||||
`resolveTerminalConnectionStatus`), so the same ban can surface as either
|
||||
`deactivated` or `expired` depending on which path handled the response. (The
|
||||
@@ -86,7 +86,7 @@ every failed upstream request flows through — it is **not** gated to
|
||||
OAuth/subscription scrapers. The resulting terminal state is per **connection**,
|
||||
not per provider.
|
||||
|
||||
That said, the built-in *strings* are oriented toward subscription/OAuth
|
||||
That said, the built-in _strings_ are oriented toward subscription/OAuth
|
||||
providers with real ban risk (ChatGPT Web, Claude Web, Codex, Muse Spark,
|
||||
Antigravity). An API-key provider will only trip the detector if its error body
|
||||
literally contains one of the substrings.
|
||||
@@ -136,14 +136,62 @@ There is no separate "clear ban flag" button — recovery is re-test, re-auth, o
|
||||
re-enable, matching the general terminal-state rule in
|
||||
[RESILIENCE_GUIDE](../architecture/RESILIENCE_GUIDE.md).
|
||||
|
||||
## Probe isolation (model test-all)
|
||||
|
||||
A **probe-origin failure** (model test-all / health-check dispatches executed
|
||||
inside `runAsProbe`) never removes a connection from the pool (#9817): it is
|
||||
**recorded for visibility** (`last_error`, `last_error_type`, `error_code`,
|
||||
`last_error_at`) but skips **every** routing mutation — cooldowns, terminal
|
||||
status (`banned` / `deactivated` / `credits_exhausted`), per-model lockouts,
|
||||
the provider circuit breaker, the 5-minute quota cache, OAuth token refresh
|
||||
and auto-disable. Only a real request-path failure deactivates. The recorded
|
||||
error is what makes a flagged account visible in the dashboard while it stays
|
||||
serving traffic.
|
||||
|
||||
The single decision point is `shouldIsolateProbeFailures()`
|
||||
(`src/shared/utils/probeOrigin.ts`), consulted by **every** site that could
|
||||
mutate routing state from a probe-origin failure:
|
||||
|
||||
- `markAccountUnavailable` (`auth.ts`) — record-only (`lastError` raw text,
|
||||
`lastErrorType`, `errorCode`, `lastErrorAt`; deliberately **no**
|
||||
`backoffLevel`, which would trigger the selection-time auto-decay and wipe
|
||||
the record)
|
||||
- `maybeAutoDisableBannedAccount` — no auto-disable
|
||||
- `chatCore` — FORBIDDEN, ACCOUNT_DEACTIVATED, QUOTA_EXHAUSTED (record-only,
|
||||
no terminal `credits_exhausted`), GEO_BLOCKED (no 24h exclusion),
|
||||
MODEL_NOT_FOUND (no `lockModel`), the codex 429 account-rotation failover
|
||||
(no `markCodexScopeRateLimited`, no persisted `rate_limited_until`, no
|
||||
session-affinity clear), `persistCodexQuotaState` (no quota-state write,
|
||||
no cache invalidation), `recordKeyHealthStatus` (key-health rotator
|
||||
untouched)
|
||||
- OAuth refresh — both the proactive refresh in the executor base
|
||||
(`base.ts` `execute()`, no refresh-token rotation consumed) and the
|
||||
reactive 401/403 path in `chatCore` (no `expired` deactivation)
|
||||
- `chat.ts` — provider circuit breaker and the 5-minute quota cache
|
||||
(`markAccountExhaustedFrom429`) never degraded
|
||||
|
||||
The recorded error is what makes a flagged account visible in the dashboard
|
||||
while it stays serving traffic. Note: the probe record stores the **raw**
|
||||
(unsliced) error text, unlike the real path's `slice(0,100)` truncation.
|
||||
|
||||
Operators who use test-all as a maintenance tool can restore the historical
|
||||
behavior (probe counts as a real generation) via either:
|
||||
|
||||
- the `probeCanDisable` setting (`POST /api/settings` with
|
||||
`{"probeCanDisable": true}`, or a direct `key_value` DB edit), or
|
||||
- feature flag **`PROBE_CAN_DISABLE=true`** (env or DB override; wins over the
|
||||
setting).
|
||||
|
||||
Fail-safe: if the flag or settings lookup throws, isolation stays ON.
|
||||
|
||||
## Source files
|
||||
|
||||
| Concern | File |
|
||||
| --- | --- |
|
||||
| Signal tables + match | `open-sse/services/accountFallback.ts` |
|
||||
| Terminalization / persistence | `src/sse/services/auth.ts` (`markAccountUnavailable`, `resolveTerminalConnectionStatus`, `clearAccountError`) |
|
||||
| Auto-disable scope | `src/shared/utils/autoDisableBanned.ts`, `src/sse/services/autoDisableBannedAccount.ts` |
|
||||
| Inline classification | `open-sse/handlers/chatCore.ts`, `open-sse/services/errorClassifier.ts` |
|
||||
| Terminal-state recovery exclusion | `src/lib/quota/connectionRecovery.ts` |
|
||||
| Custom-keyword runtime load | `src/lib/config/runtimeSettings.ts` (`setCustomBannedSignals`) |
|
||||
| Settings UI | `src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx` |
|
||||
| Concern | File |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Signal tables + match | `open-sse/services/accountFallback.ts` |
|
||||
| Terminalization / persistence | `src/sse/services/auth.ts` (`markAccountUnavailable`, `resolveTerminalConnectionStatus`, `clearAccountError`) |
|
||||
| Auto-disable scope | `src/shared/utils/autoDisableBanned.ts`, `src/sse/services/autoDisableBannedAccount.ts` |
|
||||
| Inline classification | `open-sse/handlers/chatCore.ts`, `open-sse/services/errorClassifier.ts` |
|
||||
| Terminal-state recovery exclusion | `src/lib/quota/connectionRecovery.ts` |
|
||||
| Custom-keyword runtime load | `src/lib/config/runtimeSettings.ts` (`setCustomBannedSignals`) |
|
||||
| Settings UI | `src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx` |
|
||||
|
||||
@@ -20,21 +20,26 @@ password on every invocation.
|
||||
(falls back to an empty string on failure, disabling CLI auth).
|
||||
2. It computes `HMAC-SHA256(machine_id, salt)` and returns the full 64-char
|
||||
hex digest — a deterministic, non-reversible token tied to this machine.
|
||||
3. The CLI sends the token as `x-omniroute-cli-token` on every request to
|
||||
`http://localhost:<port>/api/...`.
|
||||
3. The CLI sends the token as `x-omniroute-cli-token` only when the resolved
|
||||
destination is an explicit loopback URL (`localhost`, `127.0.0.0/8`, or
|
||||
loopback IPv6). Requests carrying the token use `redirect: error`, so a local
|
||||
redirect cannot forward it to another origin. Remote contexts use scoped
|
||||
access tokens instead. If derivation is unavailable, the CLI omits the header
|
||||
and `omniroute doctor` reports the failure instead of treating an empty token
|
||||
as valid.
|
||||
4. The server (`src/server/authz/policies/management.ts`) recomputes the
|
||||
expected token with the same salt and compares via `timingSafeEqual` to
|
||||
prevent timing-based extraction.
|
||||
|
||||
## Security properties
|
||||
|
||||
| Property | Detail |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Loopback-only** | Accepted only when `Host` is `localhost`, `127.0.0.1`, or `::1`. |
|
||||
| **Constant-time compare** | `crypto.timingSafeEqual` prevents timing attacks. |
|
||||
| **Non-reversible** | HMAC output cannot recover the machine-id. |
|
||||
| **No `always`-protected bypass** | `isAlwaysProtectedPath()` is evaluated before the CLI token check. `/api/shutdown` and `/api/settings/database` always require JWT. |
|
||||
| **Non-exportable** | Token is never written to disk or logged. |
|
||||
| Property | Detail |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Loopback-only** | Accepted only when the server's trusted peer-locality stamp (derived from the real TCP peer address) says loopback. The client-controlled `Host` header is never trusted for locality. |
|
||||
| **Constant-time compare** | `crypto.timingSafeEqual` prevents timing attacks. |
|
||||
| **Non-reversible** | HMAC output cannot recover the machine-id. |
|
||||
| **No `always`-protected bypass** | `isAlwaysProtectedPath()` is evaluated before the CLI token check. `/api/shutdown` and `/api/settings/database` always require JWT. |
|
||||
| **Non-exportable** | Token is never written to disk or logged. |
|
||||
|
||||
## Salt rotation
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ Two separate retention windows are honoured:
|
||||
| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Row-cap trim for `proxy_logs` |
|
||||
|
||||
`cleanupExpiredLogs()` runs the retention pass. It is invoked on server startup
|
||||
from `src/server-init.ts` and `src/instrumentation-node.ts`. Each run logs a
|
||||
from `src/instrumentation-node.ts`. Each run logs a
|
||||
`compliance.cleanup` audit event with the per-table delete counts. Proxy/call
|
||||
log trimming is batched (`BATCH_SIZE = 5000`) to avoid long write locks.
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ const path = require("path");
|
||||
|
||||
/**
|
||||
* remoteServerPreferences.js — pure read/write helpers for the small JSON
|
||||
* preferences file that persists the operator-configured remote server URL
|
||||
* across app restarts (see resolveRemoteServerUrl.js for how it's consumed).
|
||||
* preferences file that persists desktop-shell choices needed before the
|
||||
* server-owned settings database is available.
|
||||
*
|
||||
* Deliberately a plain flat JSON file rather than the app's SQLite database:
|
||||
* this preference must be readable before deciding whether to spawn (or even
|
||||
@@ -18,19 +18,20 @@ const path = require("path");
|
||||
* @param {string} prefsPath - absolute path to electron-preferences.json
|
||||
* @param {(p: string) => boolean} [existsSync]
|
||||
* @param {(p: string, enc: string) => string} [readFileSync]
|
||||
* @returns {{remoteServerUrl: string|null}}
|
||||
* @returns {{remoteServerUrl: string|null, closeBehavior: "keep-loaded"|"unload"}}
|
||||
*/
|
||||
function readPreferences(prefsPath, existsSync = fs.existsSync, readFileSync = fs.readFileSync) {
|
||||
if (!existsSync(prefsPath)) return { remoteServerUrl: null };
|
||||
if (!existsSync(prefsPath)) return { remoteServerUrl: null, closeBehavior: "keep-loaded" };
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(prefsPath, "utf8"));
|
||||
const remoteServerUrl =
|
||||
typeof parsed.remoteServerUrl === "string" && parsed.remoteServerUrl.trim()
|
||||
? parsed.remoteServerUrl.trim()
|
||||
: null;
|
||||
return { remoteServerUrl };
|
||||
const closeBehavior = parsed.closeBehavior === "unload" ? "unload" : "keep-loaded";
|
||||
return { remoteServerUrl, closeBehavior };
|
||||
} catch {
|
||||
return { remoteServerUrl: null };
|
||||
return { remoteServerUrl: null, closeBehavior: "keep-loaded" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,4 +73,35 @@ function writeRemoteServerUrl(
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { readPreferences, writeRemoteServerUrl };
|
||||
/** Persist whether closing the dashboard hides it or unloads its renderer. */
|
||||
function writeCloseBehavior(
|
||||
prefsPath,
|
||||
closeBehavior,
|
||||
{
|
||||
existsSync = fs.existsSync,
|
||||
readFileSync = fs.readFileSync,
|
||||
writeFileSync = fs.writeFileSync,
|
||||
mkdirSync = fs.mkdirSync,
|
||||
} = {}
|
||||
) {
|
||||
try {
|
||||
const dir = path.dirname(prefsPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const current = readPreferences(prefsPath, existsSync, readFileSync);
|
||||
const next = {
|
||||
...current,
|
||||
closeBehavior: closeBehavior === "unload" ? "unload" : "keep-loaded",
|
||||
};
|
||||
writeFileSync(prefsPath, JSON.stringify(next, null, 2) + "\n", "utf8");
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[remoteServerPreferences] Failed to write preferences to ${prefsPath}:`,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { readPreferences, writeRemoteServerUrl, writeCloseBehavior };
|
||||
|
||||
26
electron/lib/windowClosePolicy.js
Normal file
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
|
||||
const CLOSE_BEHAVIOR_KEEP_LOADED = "keep-loaded";
|
||||
const CLOSE_BEHAVIOR_UNLOAD = "unload";
|
||||
|
||||
function normalizeCloseBehavior(value) {
|
||||
if (value === CLOSE_BEHAVIOR_KEEP_LOADED || value === CLOSE_BEHAVIOR_UNLOAD) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveRendererUrl(currentUrl, serverUrl) {
|
||||
try {
|
||||
const current = new URL(currentUrl);
|
||||
const server = new URL(serverUrl);
|
||||
return current.origin === server.origin ? current.href : server.href;
|
||||
} catch {
|
||||
return serverUrl;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CLOSE_BEHAVIOR_KEEP_LOADED,
|
||||
CLOSE_BEHAVIOR_UNLOAD,
|
||||
normalizeCloseBehavior,
|
||||
resolveRendererUrl,
|
||||
};
|
||||
28
electron/lib/windowLifecycle.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/** Pure helpers for deciding and driving the Electron dashboard window lifecycle. */
|
||||
|
||||
function shouldStartHidden({ argv = [], loginItemSettings = {} } = {}) {
|
||||
return (
|
||||
argv.includes("--hidden") ||
|
||||
argv.includes("--minimized") ||
|
||||
loginItemSettings.wasOpenedAsHidden === true
|
||||
);
|
||||
}
|
||||
|
||||
function showOrCreateWindow({ appReady, getWindow, createWindow }) {
|
||||
if (!appReady) return null;
|
||||
|
||||
const currentWindow = getWindow();
|
||||
if (!currentWindow || currentWindow.isDestroyed()) {
|
||||
return createWindow();
|
||||
}
|
||||
|
||||
if (currentWindow.isMinimized()) currentWindow.restore();
|
||||
currentWindow.show();
|
||||
currentWindow.focus();
|
||||
return currentWindow;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
shouldStartHidden,
|
||||
showOrCreateWindow,
|
||||
};
|
||||
158
electron/main.js
@@ -38,8 +38,19 @@ const { killProcessTree } = require("./processTree");
|
||||
const { resolveServerEntry } = require("./lib/resolveServerEntry");
|
||||
const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper");
|
||||
const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl");
|
||||
const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences");
|
||||
const {
|
||||
readPreferences,
|
||||
writeRemoteServerUrl,
|
||||
writeCloseBehavior,
|
||||
} = require("./lib/remoteServerPreferences");
|
||||
const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness");
|
||||
const { shouldStartHidden, showOrCreateWindow } = require("./lib/windowLifecycle");
|
||||
const {
|
||||
CLOSE_BEHAVIOR_KEEP_LOADED,
|
||||
CLOSE_BEHAVIOR_UNLOAD,
|
||||
normalizeCloseBehavior,
|
||||
resolveRendererUrl,
|
||||
} = require("./lib/windowClosePolicy");
|
||||
|
||||
// ── Single Instance Lock ───────────────────────────────────
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
@@ -49,11 +60,12 @@ if (!gotTheLock) {
|
||||
}
|
||||
|
||||
app.on("second-instance", () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
const isHeadless =
|
||||
process.argv.includes("--headless") ||
|
||||
process.argv.includes("--cli") ||
|
||||
process.env.OMNIROUTE_HEADLESS === "true";
|
||||
if (isHeadless) return;
|
||||
showMainWindow();
|
||||
});
|
||||
|
||||
// ── Environment Detection ──────────────────────────────────
|
||||
@@ -71,6 +83,8 @@ let nextServer = null;
|
||||
let serverPort = 20128;
|
||||
let isServerStopped = false;
|
||||
let remoteServerPromptWindow = null;
|
||||
let keepAliveWithoutWindows = false;
|
||||
let lastRendererUrl = null;
|
||||
|
||||
// ── Remote Server Mode ──────────────────────────────────────
|
||||
// Lets the desktop shell attach to an already-running OmniRoute server (e.g. a
|
||||
@@ -81,6 +95,8 @@ const REMOTE_SERVER_PREFS_PATH = path.join(
|
||||
resolveDataDir(null, process.env),
|
||||
"electron-preferences.json"
|
||||
);
|
||||
const electronPreferences = readPreferences(REMOTE_SERVER_PREFS_PATH);
|
||||
let closeBehavior = electronPreferences.closeBehavior;
|
||||
let remoteServerUrl = resolveRemoteServerUrl({
|
||||
env: process.env,
|
||||
prefsPath: REMOTE_SERVER_PREFS_PATH,
|
||||
@@ -365,14 +381,18 @@ function setupContentSecurityPolicy() {
|
||||
}
|
||||
|
||||
// ── Create Window ──────────────────────────────────────────
|
||||
function createWindow() {
|
||||
function createWindow({ showWhenReady = true } = {}) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
|
||||
|
||||
const rendererStartedAt = Date.now();
|
||||
|
||||
// Platform-conditional options (#9)
|
||||
const platformWindowOptions =
|
||||
process.platform === "darwin"
|
||||
? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } }
|
||||
: { titleBarStyle: "default" };
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
const window = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
@@ -390,28 +410,28 @@ function createWindow() {
|
||||
backgroundColor: "#0a0a0a",
|
||||
...platformWindowOptions,
|
||||
});
|
||||
mainWindow = window;
|
||||
|
||||
// Load the Next.js app
|
||||
mainWindow.loadURL(getServerUrl());
|
||||
window.loadURL(resolveRendererUrl(lastRendererUrl, getServerUrl()));
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools({ mode: "detach" });
|
||||
window.webContents.openDevTools({ mode: "detach" });
|
||||
}
|
||||
|
||||
// Show window when ready (unless starting minimized/hidden in tray)
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
const startHidden =
|
||||
process.argv.includes("--hidden") ||
|
||||
process.argv.includes("--minimized") ||
|
||||
app.getLoginItemSettings().wasOpenedAsHidden;
|
||||
if (!startHidden) {
|
||||
mainWindow.show();
|
||||
// Hidden startup (createWindow({ showWhenReady: false })) skips the initial
|
||||
// show(); the window stays created (so tray/dock interactions work) but the
|
||||
// renderer only becomes visible on the next explicit showMainWindow() call.
|
||||
window.once("ready-to-show", () => {
|
||||
console.log(`[Electron] Renderer ready in ${Date.now() - rendererStartedAt}ms`);
|
||||
if (showWhenReady) {
|
||||
window.show();
|
||||
} else {
|
||||
console.log("[Electron] Launched hidden in background tray");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle external links — validate URL protocol to prevent RCE
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (["http:", "https:"].includes(parsedUrl.protocol)) {
|
||||
@@ -425,18 +445,44 @@ function createWindow() {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Handle window close — minimize to tray
|
||||
mainWindow.on("close", (event) => {
|
||||
// Keep the server alive while either hiding the renderer for a fast reopen or
|
||||
// unloading it to reclaim memory, according to the persisted tray preference.
|
||||
window.on("close", (event) => {
|
||||
if (!app.isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
lastRendererUrl = resolveRendererUrl(window.webContents.getURL(), getServerUrl());
|
||||
if (closeBehavior === CLOSE_BEHAVIOR_UNLOAD) {
|
||||
console.log("[Electron] Dashboard renderer unloaded; server remains running");
|
||||
window.destroy();
|
||||
} else {
|
||||
console.log("[Electron] Dashboard hidden; renderer kept loaded");
|
||||
window.hide();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
window.on("closed", () => {
|
||||
if (mainWindow === window) mainWindow = null;
|
||||
});
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
function showMainWindow() {
|
||||
return showOrCreateWindow({
|
||||
appReady: app.isReady(),
|
||||
getWindow: () => mainWindow,
|
||||
createWindow,
|
||||
});
|
||||
}
|
||||
|
||||
function setCloseBehavior(nextBehavior) {
|
||||
const normalized = normalizeCloseBehavior(nextBehavior);
|
||||
if (!normalized || normalized === closeBehavior) return;
|
||||
closeBehavior = normalized;
|
||||
writeCloseBehavior(REMOTE_SERVER_PREFS_PATH, closeBehavior);
|
||||
createTray();
|
||||
}
|
||||
|
||||
// ── System Tray ────────────────────────────────────────────
|
||||
@@ -465,12 +511,7 @@ function createTray() {
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: "Open OmniRoute",
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
},
|
||||
click: () => showMainWindow(),
|
||||
},
|
||||
{
|
||||
label: "Open Dashboard",
|
||||
@@ -504,6 +545,23 @@ function createTray() {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "When Dashboard Closes",
|
||||
submenu: [
|
||||
{
|
||||
label: "Keep Loaded (Faster Reopen)",
|
||||
type: "radio",
|
||||
checked: closeBehavior === CLOSE_BEHAVIOR_KEEP_LOADED,
|
||||
click: () => setCloseBehavior(CLOSE_BEHAVIOR_KEEP_LOADED),
|
||||
},
|
||||
{
|
||||
label: "Unload Renderer (Lower Memory)",
|
||||
type: "radio",
|
||||
checked: closeBehavior === CLOSE_BEHAVIOR_UNLOAD,
|
||||
click: () => setCloseBehavior(CLOSE_BEHAVIOR_UNLOAD),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Check for Updates",
|
||||
@@ -522,12 +580,7 @@ function createTray() {
|
||||
tray.setToolTip("OmniRoute");
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on("double-click", () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
tray.on("double-click", () => showMainWindow());
|
||||
}
|
||||
|
||||
// ── Change Port (#3: now restarts server) ──────────────────
|
||||
@@ -549,6 +602,7 @@ async function changePort(newPort) {
|
||||
await waitForServer(getServerReadinessUrl());
|
||||
|
||||
// Reload window and update tray
|
||||
lastRendererUrl = getServerUrl();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.loadURL(getServerUrl());
|
||||
}
|
||||
@@ -613,6 +667,7 @@ async function setRemoteServerUrl(nextUrl) {
|
||||
|
||||
remoteServerUrl = normalized;
|
||||
writeRemoteServerUrl(REMOTE_SERVER_PREFS_PATH, remoteServerUrl);
|
||||
lastRendererUrl = getServerUrl();
|
||||
|
||||
startNextServer();
|
||||
try {
|
||||
@@ -1094,9 +1149,20 @@ app.whenReady().then(async () => {
|
||||
process.argv.includes("--headless") ||
|
||||
process.argv.includes("--cli") ||
|
||||
process.env.OMNIROUTE_HEADLESS === "true";
|
||||
const startHidden =
|
||||
!isHeadless &&
|
||||
shouldStartHidden({
|
||||
argv: process.argv,
|
||||
loginItemSettings: app.getLoginItemSettings(),
|
||||
});
|
||||
keepAliveWithoutWindows = startHidden;
|
||||
|
||||
// Fix #1: Start server and WAIT for readiness before showing window
|
||||
startNextServer();
|
||||
if (!isHeadless) {
|
||||
createTray();
|
||||
}
|
||||
|
||||
let serverReady = true;
|
||||
if (!isDev) {
|
||||
// Probe the lightweight auth-exempt endpoint instead of aggregating full monitoring state.
|
||||
@@ -1105,9 +1171,10 @@ app.whenReady().then(async () => {
|
||||
|
||||
if (isHeadless) {
|
||||
console.log("[Electron] Headless mode active — UI window and tray icon skipped");
|
||||
} else if (startHidden) {
|
||||
console.log("[Electron] Launched hidden in background tray without a renderer");
|
||||
} else {
|
||||
createWindow();
|
||||
createTray();
|
||||
showMainWindow();
|
||||
}
|
||||
|
||||
setupIpcHandlers();
|
||||
@@ -1115,7 +1182,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
// If readiness timed out (e.g. very long first-launch migrations), don't leave the
|
||||
// window stuck on a hanging connection — keep polling and reload once it responds (#2460).
|
||||
if (!isDev && !serverReady && !isHeadless) {
|
||||
if (!isDev && !serverReady && !isHeadless && !startHidden) {
|
||||
void waitForServer(getServerReadinessUrl(), 300000).then((ready) => {
|
||||
if (ready && mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.loadURL(getServerUrl());
|
||||
@@ -1133,11 +1200,7 @@ app.whenReady().then(async () => {
|
||||
// macOS: recreate window when dock icon clicked
|
||||
app.on("activate", () => {
|
||||
if (isHeadless) return;
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
} else if (mainWindow) {
|
||||
mainWindow.show();
|
||||
}
|
||||
showMainWindow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1147,7 +1210,12 @@ app.on("window-all-closed", () => {
|
||||
process.argv.includes("--headless") ||
|
||||
process.argv.includes("--cli") ||
|
||||
process.env.OMNIROUTE_HEADLESS === "true";
|
||||
if (process.platform !== "darwin" && !isHeadless) {
|
||||
if (
|
||||
process.platform !== "darwin" &&
|
||||
!isHeadless &&
|
||||
!keepAliveWithoutWindows &&
|
||||
closeBehavior !== CLOSE_BEHAVIOR_UNLOAD
|
||||
) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,9 +64,11 @@
|
||||
"remoteServerPromptRenderer.js",
|
||||
"lib/resolveServerEntry.js",
|
||||
"lib/resolveNodeHelper.js",
|
||||
"lib/windowLifecycle.js",
|
||||
"lib/resolveRemoteServerUrl.js",
|
||||
"lib/remoteServerPreferences.js",
|
||||
"lib/serverReadiness.js",
|
||||
"lib/windowClosePolicy.js",
|
||||
"assets/remoteServerPrompt.html",
|
||||
"package.json",
|
||||
"node_modules/**/*"
|
||||
|
||||
8
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 342 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 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 343 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -165,7 +165,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
│ │ └── manager.ts # MITM proxy manager
|
||||
│ ├── shared/ # Shared utilities, components, and constants
|
||||
│ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.)
|
||||
│ │ ├── constants/ # Provider definitions (342), model lists, pricing, routing strategies, MCP scopes
|
||||
│ │ ├── constants/ # Provider definitions (343), model lists, pricing, routing strategies, MCP scopes
|
||||
│ │ ├── contracts/ # Shared API contracts
|
||||
│ │ ├── hooks/ # React hooks
|
||||
│ │ ├── middleware/ # Shared middleware utilities
|
||||
@@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
|
||||
## Key Features (v3.8.50)
|
||||
|
||||
### Core Proxy
|
||||
- **342 AI providers** with automatic format translation
|
||||
- **343 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
|
||||
@@ -475,7 +475,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
|
||||
|
||||
## v3.8.x Highlights
|
||||
|
||||
- **342-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
|
||||
- **343-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
|
||||
- **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay`
|
||||
- **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown
|
||||
- **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules)
|
||||
|
||||
@@ -41,6 +41,15 @@ export const AGY_PUBLIC_MODELS = Object.freeze([
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.7-flash-tiered",
|
||||
name: "Gemini 3.7 Flash (Tiered)",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
// Gemini 3.1 Pro
|
||||
{
|
||||
id: "gemini-pro-agent",
|
||||
|
||||
@@ -29,6 +29,15 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.7-flash-tiered",
|
||||
name: "Gemini 3.7 Flash (Tiered)",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 65536,
|
||||
supportsReasoning: true,
|
||||
supportsVision: true,
|
||||
toolCalling: true,
|
||||
},
|
||||
// Gemini 3.1 Pro budget tiers. Live streamGenerateContent validation uses
|
||||
// `gemini-pro-agent` for High; the separately advertised `gemini-3.1-pro-high`
|
||||
// discovery slot currently returns HTTP 400 and is intentionally not public.
|
||||
@@ -91,6 +100,13 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([
|
||||
]);
|
||||
|
||||
export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({
|
||||
// Gemini 3.7 Flash tiers map to the upstream tiered endpoint model; the thinking
|
||||
// budget is steered via generationConfig.thinkingConfig.thinkingBudget.
|
||||
"gemini-3.7-flash": "gemini-3.7-flash-tiered",
|
||||
"gemini-3.7-flash-high": "gemini-3.7-flash-tiered",
|
||||
"gemini-3.7-flash-medium": "gemini-3.7-flash-tiered",
|
||||
"gemini-3.7-flash-low": "gemini-3.7-flash-tiered",
|
||||
"gpt-oss-120b": "gpt-oss-120b-medium",
|
||||
// gemini-3.1-pro-low is not aliased: the upstream accepts it verbatim.
|
||||
// gemini-3.1-pro-high: the discovery slot returns HTTP 400 on v1internal;
|
||||
// the live upstream id is gemini-pro-agent (see ANTIGRAVITY_PUBLIC_MODELS).
|
||||
|
||||
@@ -81,6 +81,10 @@ export const COOLDOWN_MS = {
|
||||
// account, so re-probe only after a long window (or when the operator routes
|
||||
// egress through a supported-region proxy).
|
||||
geoBlocked: 24 * 60 * 60 * 1000,
|
||||
// Antigravity BYOP (GCP_PROJECT_REQUIRED): nothing changes on the account
|
||||
// until the operator enters a Project ID, so keep the connection excluded
|
||||
// from selection for a long window (mirrors the geo-blocked treatment).
|
||||
gcpProjectRequired: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,9 +16,11 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
|
||||
* rewrites file timestamps on every deploy, which would report a months-old
|
||||
* catalog as "updated today". Bump this whenever the entries below change.
|
||||
*/
|
||||
export const FREE_CATALOG_CURATED_AT = "2026-08-16";
|
||||
export const FREE_CATALOG_CURATED_AT = "2026-08-18";
|
||||
|
||||
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "chatgpt-web", modelId: "gpt-5.6-luna-free", displayName: "GPT-5.6 Luna (Free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" },
|
||||
{ provider: "chatgpt-web", modelId: "gpt-5.6-luna-free-thinking", displayName: "GPT-5.6 Luna (Free, Think)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" },
|
||||
{ provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
|
||||
{ provider: "agentrouter", modelId: "claude-opus-5", displayName: "Claude Opus 5", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
|
||||
{ provider: "agentrouter", modelId: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
|
||||
|
||||
@@ -11,6 +11,7 @@ export const GROK_BUILD_TOKEN_URL = `${GROK_BUILD_OAUTH_ISSUER}/oauth2/token`;
|
||||
export const GROK_BUILD_DEFAULT_CLIENT_VERSION = "0.2.106";
|
||||
export const GROK_BUILD_DEFAULT_CONTEXT_WINDOW = 256_000;
|
||||
export const GROK_BUILD_DEFAULT_REASONING_EFFORT = "high";
|
||||
export const GROK_BUILD_SUPPORTED_REASONING_EFFORTS = Object.freeze(["low", "medium", "high"]);
|
||||
export const GROK_BUILD_CLIENT_IDENTIFIER = "grok-shell";
|
||||
export const GROK_BUILD_TOKEN_AUTH = "xai-grok-cli";
|
||||
export const GROK_BUILD_REASONING_INCLUDE = "reasoning.encrypted_content";
|
||||
|
||||
@@ -210,6 +210,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
|
||||
authHeader: "bearer",
|
||||
format: "openai", // native OpenAI format
|
||||
models: [
|
||||
{ id: "dall-e-3", name: "DALL·E 3" },
|
||||
{ id: "gpt-image-2", name: "GPT Image 2" },
|
||||
{ id: "gpt-image-1.5", name: "GPT Image 1.5" },
|
||||
{ id: "gpt-image-1-mini", name: "GPT Image 1 Mini" },
|
||||
@@ -267,6 +268,25 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
|
||||
supportedSizes: ["1024x1024", "1024x1536", "1536x1024"],
|
||||
},
|
||||
|
||||
// Cursor plan image generation via the Agent CLI native `generateImage` tool.
|
||||
// Reuses the same OAuth/API-key connection as chat (`provider: "cursor"`).
|
||||
// Requires the `agent` binary (CURSOR_AGENT_BIN) — see cursorAgentImage handler.
|
||||
cursor: {
|
||||
id: "cursor",
|
||||
alias: "cu",
|
||||
// Sentinel: execution is local Agent CLI, not an HTTP image API.
|
||||
baseUrl: "agent://cursor-agent",
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
format: "cursor-agent-image",
|
||||
models: [
|
||||
{ id: "auto", name: "Cursor Auto (Image)" },
|
||||
{ id: "composer-2", name: "Composer 2 (Image)" },
|
||||
{ id: "composer-2.5", name: "Composer 2.5 (Image)" },
|
||||
],
|
||||
supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "1024x1536", "1536x1024"],
|
||||
},
|
||||
|
||||
"microsoft-designer-web": {
|
||||
id: "microsoft-designer-web",
|
||||
alias: "msdesigner",
|
||||
|
||||
@@ -9,12 +9,83 @@ export const chatgpt_webProvider: RegistryEntry = {
|
||||
authType: "apikey",
|
||||
authHeader: "cookie",
|
||||
models: [
|
||||
{ id: "gpt-5.6-pro", name: "GPT-5.6 Pro", toolCalling: false }, // pro tier only, standard effort
|
||||
{ id: "gpt-5.6-thinking", name: "GPT-5.6 Thinking", toolCalling: false }, // plus, pro tier
|
||||
{ id: "gpt-5.5-pro-extended", name: "GPT-5.5 Pro Extended", toolCalling: false }, // pro tier only, extended effort
|
||||
{ id: "gpt-5.5-pro", name: "GPT-5.5 Pro", toolCalling: false }, // pro tier only, standard effort
|
||||
{ id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking", toolCalling: false }, // plus, pro tier
|
||||
{ id: "gpt-5.5", name: "GPT-5.5 Instant", toolCalling: false }, // free, plus, pro tier
|
||||
{ id: "o3", name: "o3", toolCalling: false }, // plus ~ tier
|
||||
{
|
||||
id: "gpt-5.6-sol-pro",
|
||||
name: "GPT-5.6 Sol (Pro)",
|
||||
liveCatalogIds: ["gpt-5-6-pro"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.6-sol-xhigh",
|
||||
name: "GPT-5.6 Sol (Xhigh)",
|
||||
liveCatalogIds: ["gpt-5-6-thinking"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.6-sol-high",
|
||||
name: "GPT-5.6 Sol (High)",
|
||||
liveCatalogIds: ["gpt-5-6-thinking"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.6-sol-medium",
|
||||
name: "GPT-5.6 Sol (Medium)",
|
||||
liveCatalogIds: ["gpt-5-6-thinking"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.6-sol-instant",
|
||||
name: "GPT-5.6 Sol (Instant)",
|
||||
liveCatalogIds: ["gpt-5-6"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.6-luna-free-thinking",
|
||||
name: "GPT-5.6 Luna (Free, Think)",
|
||||
liveCatalogIds: ["gpt-5-6"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.6-luna-free",
|
||||
name: "GPT-5.6 Luna (Free)",
|
||||
liveCatalogIds: ["gpt-5-6"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5-pro-extended",
|
||||
name: "GPT-5.5 (Pro Extended)",
|
||||
liveCatalogIds: ["gpt-5-5-pro"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5-pro",
|
||||
name: "GPT-5.5 (Pro)",
|
||||
liveCatalogIds: ["gpt-5-5-pro"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5-xhigh",
|
||||
name: "GPT-5.5 (Xhigh)",
|
||||
liveCatalogIds: ["gpt-5-5-thinking"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5-high",
|
||||
name: "GPT-5.5 (High)",
|
||||
liveCatalogIds: ["gpt-5-5-thinking"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5-medium",
|
||||
name: "GPT-5.5 (Medium)",
|
||||
liveCatalogIds: ["gpt-5-5-thinking"],
|
||||
toolCalling: false,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5-instant",
|
||||
name: "GPT-5.5 (Instant)",
|
||||
liveCatalogIds: ["gpt-5-5"],
|
||||
toolCalling: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -46,6 +46,12 @@ export interface RegistryModel {
|
||||
id: string;
|
||||
name: string;
|
||||
aliases?: readonly string[];
|
||||
/**
|
||||
* Upstream model IDs that prove this static model is live when the provider
|
||||
* has an authoritative synchronized catalog. Needed for curated IDs whose
|
||||
* public name differs from the ID sent to the upstream service.
|
||||
*/
|
||||
liveCatalogIds?: readonly string[];
|
||||
toolCalling?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
supportedThinkingEfforts?: readonly string[];
|
||||
|
||||
@@ -103,6 +103,7 @@ import {
|
||||
} from "./base/headers.ts";
|
||||
import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting";
|
||||
import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth";
|
||||
import { isProbeContext } from "@/shared/utils/probeOrigin";
|
||||
// Header helpers extracted to a pure leaf; re-exported for external importers
|
||||
// (executors + tests) that import them from "./base.ts".
|
||||
export {
|
||||
@@ -689,7 +690,10 @@ export class BaseExecutor {
|
||||
// Track per-URL intra-retry attempts to avoid infinite loops
|
||||
const retryAttemptsByUrl: Record<number, number> = {};
|
||||
|
||||
if (this.needsRefresh(credentials)) {
|
||||
// Probe-origin dispatches must not consume a refresh-token rotation —
|
||||
// routing state untouched; the reactive 401/403 path is probe-guarded
|
||||
// in chatCore (#9817).
|
||||
if (!isProbeContext() && this.needsRefresh(credentials)) {
|
||||
try {
|
||||
// Fix A: wire onCredentialsRefreshed through runWithOnPersist so it runs
|
||||
// INSIDE the per-connection mutex inside getAccessToken. Not every
|
||||
@@ -800,7 +804,14 @@ export class BaseExecutor {
|
||||
activeCredentials
|
||||
);
|
||||
const url = this.buildUrl(model, stream, urlIndex, requestCredentials);
|
||||
const headers = this.buildHeaders(requestCredentials, stream, clientHeaders, model, undefined, body);
|
||||
const headers = this.buildHeaders(
|
||||
requestCredentials,
|
||||
stream,
|
||||
clientHeaders,
|
||||
model,
|
||||
undefined,
|
||||
body
|
||||
);
|
||||
applyConfiguredUserAgent(headers, requestCredentials?.providerSpecificData);
|
||||
|
||||
// Strip OpenAI SDK (X-Stainless-*) metadata + normalize SDK-derived User-Agent
|
||||
|
||||
@@ -32,7 +32,11 @@ import {
|
||||
__resetChatGptImageCacheForTesting,
|
||||
type ChatGptImageConversationContext,
|
||||
} from "../services/chatgptImageCache.ts";
|
||||
import { isThinkingCapableModel, resolveChatGptModel } from "./chatgpt-web/models.ts";
|
||||
import {
|
||||
resolveChatGptModel,
|
||||
resolveChatGptSystemHints,
|
||||
type ChatGptThinkingEffort,
|
||||
} from "./chatgpt-web/models.ts";
|
||||
import { cleanChatGptText } from "./chatgpt-web/citations.ts";
|
||||
import { resumeChatGptHandoff, type FinalAssistantAnswer } from "./chatgpt-web/handoff.ts";
|
||||
|
||||
@@ -43,7 +47,6 @@ const SESSION_URL = `${CHATGPT_BASE}/api/auth/session`;
|
||||
const SENTINEL_PREPARE_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements/prepare`;
|
||||
const SENTINEL_CR_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements`;
|
||||
const CONV_URL = `${CHATGPT_BASE}/backend-api/f/conversation`;
|
||||
const USER_LAST_USED_MODEL_CONFIG_URL = `${CHATGPT_BASE}/backend-api/settings/user_last_used_model_config`;
|
||||
const DEFAULT_PRO_POLL_TIMEOUT_MS = 20 * 60_000;
|
||||
const DEFAULT_PRO_POLL_INTERVAL_MS = 4_000;
|
||||
|
||||
@@ -81,10 +84,8 @@ function deviceIdFor(cookie: string): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
// OmniRoute model ID → ChatGPT internal slug. The public ChatGPT Web catalog
|
||||
// keeps OmniRoute's historical dot-form IDs (e.g. "gpt-5.5-pro"), while
|
||||
// ChatGPT's backend routes use dash-form slugs (e.g. "gpt-5-5-pro"). The slug
|
||||
// catalog comes from /backend-api/models on a logged-in account.
|
||||
// OmniRoute model IDs select a GPT-5.6 Sol performance lane. Captured browser
|
||||
// requests use one of `gpt-5-6`, `gpt-5-6-thinking`, or `gpt-5-6-pro`.
|
||||
|
||||
// ─── Browser-like default headers ──────────────────────────────────────────
|
||||
|
||||
@@ -408,25 +409,6 @@ async function runSessionWarmup(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Thinking-effort preference (PATCH user_last_used_model_config) ────────
|
||||
// chatgpt.com has two thinking levels for its dedicated thinking-models:
|
||||
// • standard — default, faster
|
||||
// • extended — longer reasoning budget
|
||||
// The browser sets the level by PATCHing `/backend-api/settings/user_last_used_model_config`
|
||||
// once, then issues the conversation request — the conversation endpoint itself
|
||||
// has no `thinking_effort` field; the server reads the user's stored preference
|
||||
// at routing time. We mirror that handshake when an OpenAI-style request
|
||||
// includes `reasoning_effort` (or a direct `providerSpecificData.thinkingEffort`
|
||||
// override).
|
||||
//
|
||||
// Cached per (cookie, slug, effort): the preference persists server-side, so
|
||||
// re-PATCHing the same combination is wasted bytes. Refreshed on TTL expiry or
|
||||
// whenever the caller switches efforts.
|
||||
|
||||
const thinkingEffortCache = new Map<string, number>();
|
||||
const THINKING_EFFORT_TTL_MS = 5 * 60 * 1000;
|
||||
const THINKING_EFFORT_CACHE_MAX = 400;
|
||||
|
||||
function configuredProPollTimeoutMs(): number {
|
||||
const raw = Number(process.env.OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS);
|
||||
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_PRO_POLL_TIMEOUT_MS;
|
||||
@@ -439,73 +421,6 @@ function configuredProPollIntervalMs(): number {
|
||||
return Math.floor(raw);
|
||||
}
|
||||
|
||||
async function setUserThinkingEffort(
|
||||
modelSlug: string,
|
||||
effort: "standard" | "extended" | "max",
|
||||
accessToken: string,
|
||||
accountId: string | null,
|
||||
sessionId: string,
|
||||
deviceId: string,
|
||||
cookie: string,
|
||||
signal: AbortSignal | null | undefined,
|
||||
log:
|
||||
| {
|
||||
debug?: (tag: string, msg: string) => void;
|
||||
warn?: (tag: string, msg: string) => void;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
): Promise<void> {
|
||||
const cacheKey = `${cookieKey(cookie)}:${modelSlug}:${effort}`;
|
||||
const now = Date.now();
|
||||
const last = thinkingEffortCache.get(cacheKey);
|
||||
if (last && now - last < THINKING_EFFORT_TTL_MS) {
|
||||
log?.debug?.("CGPT-WEB", `thinking_effort cached (${modelSlug}=${effort}) — skip PATCH`);
|
||||
return;
|
||||
}
|
||||
if (thinkingEffortCache.size >= THINKING_EFFORT_CACHE_MAX && !thinkingEffortCache.has(cacheKey)) {
|
||||
const first = thinkingEffortCache.keys().next().value;
|
||||
if (first) thinkingEffortCache.delete(first);
|
||||
}
|
||||
|
||||
const url =
|
||||
`${USER_LAST_USED_MODEL_CONFIG_URL}` +
|
||||
`?model_slug=${encodeURIComponent(modelSlug)}` +
|
||||
`&thinking_effort=${encodeURIComponent(effort)}`;
|
||||
const headers: Record<string, string> = {
|
||||
...browserHeaders(),
|
||||
...oaiHeaders(sessionId, deviceId),
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Cookie: buildSessionCookieHeader(cookie),
|
||||
Priority: "u=4",
|
||||
};
|
||||
if (accountId) headers["chatgpt-account-id"] = accountId;
|
||||
|
||||
try {
|
||||
const r = await tlsFetchChatGpt(url, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
timeoutMs: 15_000,
|
||||
signal,
|
||||
});
|
||||
if (r.status >= 400) {
|
||||
log?.warn?.(
|
||||
"CGPT-WEB",
|
||||
`thinking_effort PATCH ${r.status} for ${modelSlug}=${effort} (continuing)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
thinkingEffortCache.set(cacheKey, now);
|
||||
log?.debug?.("CGPT-WEB", `thinking_effort PATCH OK (${modelSlug}=${effort})`);
|
||||
} catch (err) {
|
||||
log?.warn?.(
|
||||
"CGPT-WEB",
|
||||
`thinking_effort PATCH failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareChatRequirements(
|
||||
accessToken: string,
|
||||
accountId: string | null,
|
||||
@@ -889,6 +804,7 @@ interface ChatGptMessage {
|
||||
id: string;
|
||||
author: { role: string };
|
||||
content: { content_type: "text"; parts: string[] };
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -984,7 +900,8 @@ function buildConversationBody(
|
||||
// chatgpt.com history. Disable Temporary Chat only when ChatGPT needs a
|
||||
// durable image conversation (image generation/editing).
|
||||
persistConversation: boolean;
|
||||
thinkingEffort: "standard" | "extended" | "max" | null;
|
||||
thinkingEffort: ChatGptThinkingEffort | null;
|
||||
systemHints: readonly string[];
|
||||
continuation?: ChatGptImageConversationContext | null;
|
||||
}
|
||||
): Record<string, unknown> {
|
||||
@@ -1021,6 +938,8 @@ function buildConversationBody(
|
||||
});
|
||||
}
|
||||
|
||||
const systemHints = options.systemHints;
|
||||
|
||||
const currentUserContent = hasOpenWebUIImageContext(parsed)
|
||||
? "Briefly acknowledge the image result described in the system context. Do not generate, edit, or request another image."
|
||||
: parsed.currentMsg || "";
|
||||
@@ -1029,6 +948,7 @@ function buildConversationBody(
|
||||
id: randomUUID(),
|
||||
author: { role: "user" },
|
||||
content: { content_type: "text", parts: [currentUserContent] },
|
||||
...(systemHints.length > 0 ? { metadata: { system_hints: [...systemHints] } } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -1051,6 +971,7 @@ function buildConversationBody(
|
||||
supports_buffering: true,
|
||||
force_parallel_switch: "auto",
|
||||
paragen_cot_summary_display_override: "allow",
|
||||
...(systemHints.length > 0 ? { system_hints: [...systemHints] } : {}),
|
||||
...(options.thinkingEffort ? { thinking_effort: options.thinkingEffort } : {}),
|
||||
};
|
||||
}
|
||||
@@ -2920,24 +2841,6 @@ export class ChatGptWebExecutor extends BaseExecutor {
|
||||
log
|
||||
);
|
||||
|
||||
// 2a''. Apply thinking-effort preference for thinking models.
|
||||
// Dedicated thinking models mirror the browser's user-config PATCH;
|
||||
// GPT-5.5 Pro effort is sent with the conversation body.
|
||||
const requestedEffort = resolvedModel.effort;
|
||||
if (requestedEffort && isThinkingCapableModel(model, modelSlug)) {
|
||||
await setUserThinkingEffort(
|
||||
modelSlug,
|
||||
requestedEffort,
|
||||
tokenEntry.accessToken,
|
||||
tokenEntry.accountId,
|
||||
sessionId,
|
||||
deviceId,
|
||||
cookie,
|
||||
signal,
|
||||
log
|
||||
);
|
||||
}
|
||||
|
||||
// 2b. Sentinel chat-requirements
|
||||
let reqs: ChatRequirements;
|
||||
try {
|
||||
@@ -3019,7 +2922,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
// Toggle Temporary Chat off only when ChatGPT needs a durable image
|
||||
// conversation. Text requests, including GPT-5.5 Pro, stay temporary so
|
||||
// conversation. Text requests, including GPT-5.6 Sol Pro, stay temporary so
|
||||
// they do not show up in the user's chatgpt.com sidebar/history.
|
||||
const imageEdit = looksLikeImageEditRequest(parsed);
|
||||
const continuation = imageEdit ? parsed.latestImageContext : null;
|
||||
@@ -3033,13 +2936,14 @@ export class ChatGptWebExecutor extends BaseExecutor {
|
||||
: "Image-gen intent detected — disabling Temporary Chat for this turn"
|
||||
);
|
||||
} else if (resolvedModel.isPro) {
|
||||
log?.debug?.("CGPT-WEB", "GPT-5.5 Pro text request — keeping Temporary Chat enabled");
|
||||
log?.debug?.("CGPT-WEB", "GPT-5.6 Sol Pro text request — keeping Temporary Chat enabled");
|
||||
}
|
||||
|
||||
const parentMessageId = continuation?.parentMessageId ?? randomUUID();
|
||||
const cgptBody = buildConversationBody(parsed, modelSlug, parentMessageId, {
|
||||
persistConversation,
|
||||
thinkingEffort: requestedEffort,
|
||||
thinkingEffort: resolvedModel.effort,
|
||||
systemHints: resolveChatGptSystemHints(model),
|
||||
continuation,
|
||||
});
|
||||
|
||||
@@ -3230,7 +3134,6 @@ function stringToStream(text: string): ReadableStream<Uint8Array> {
|
||||
export function __resetChatGptWebCachesForTesting(): void {
|
||||
tokenCache.clear();
|
||||
warmupCache.clear();
|
||||
thinkingEffortCache.clear();
|
||||
deviceIdCache.clear();
|
||||
__resetChatGptImageCacheForTesting();
|
||||
dplCache = null;
|
||||
|
||||
@@ -3,106 +3,60 @@
|
||||
|
||||
export const MODEL_MAP: Record<string, string> = {
|
||||
// ChatGPT backend slugs are also accepted directly for power users / tests.
|
||||
"gpt-5-6-pro": "gpt-5-6-pro",
|
||||
"gpt-5-6": "gpt-5-6",
|
||||
"gpt-5-6-thinking": "gpt-5-6-thinking",
|
||||
"gpt-5-5-pro": "gpt-5-5-pro",
|
||||
"gpt-5-5-pro-extended": "gpt-5-5-pro",
|
||||
"gpt-5-5-thinking": "gpt-5-5-thinking",
|
||||
"gpt-5-6-pro": "gpt-5-6-pro",
|
||||
"gpt-5-5": "gpt-5-5",
|
||||
"gpt-5-3": "gpt-5-3",
|
||||
"gpt-5-3-mini": "gpt-5-3-mini",
|
||||
"gpt-5-5-thinking": "gpt-5-5-thinking",
|
||||
"gpt-5-5-pro": "gpt-5-5-pro",
|
||||
|
||||
// Public OmniRoute dot-form ids exposed by the provider catalog.
|
||||
"gpt-5.6-pro": "gpt-5-6-pro",
|
||||
"gpt-5.6-thinking": "gpt-5-6-thinking",
|
||||
// Free accounts leave Luna selection to ChatGPT's server-side auto router.
|
||||
"gpt-5.6-luna-free": "auto",
|
||||
"gpt-5.6-luna-free-thinking": "auto",
|
||||
|
||||
// Captured from a real ChatGPT v2 picker conversation. The visible
|
||||
// performance levels select distinct backend model/effort pairs.
|
||||
"gpt-5.6-sol-instant": "gpt-5-6",
|
||||
"gpt-5.6-sol-medium": "gpt-5-6-thinking",
|
||||
"gpt-5.6-sol-high": "gpt-5-6-thinking",
|
||||
"gpt-5.6-sol-xhigh": "gpt-5-6-thinking",
|
||||
"gpt-5.6-sol-pro": "gpt-5-6-pro",
|
||||
|
||||
"gpt-5.5-instant": "gpt-5-5",
|
||||
"gpt-5.5-medium": "gpt-5-5-thinking",
|
||||
"gpt-5.5-high": "gpt-5-5-thinking",
|
||||
"gpt-5.5-xhigh": "gpt-5-5-thinking",
|
||||
"gpt-5.5-pro": "gpt-5-5-pro",
|
||||
"gpt-5.5-pro-extended": "gpt-5-5-pro",
|
||||
"gpt-5.5-thinking": "gpt-5-5-thinking",
|
||||
// Compatibility alias for existing chatgpt-web image integrations. It is
|
||||
// intentionally absent from the provider's visible curated model list.
|
||||
"gpt-5.5": "gpt-5-5",
|
||||
"gpt-5.3-instant": "gpt-5-3-instant",
|
||||
"gpt-5.3": "gpt-5-3",
|
||||
"gpt-5.3-mini": "gpt-5-3-mini",
|
||||
o3: "o3",
|
||||
};
|
||||
|
||||
export type ChatGptThinkingEffort = "standard" | "extended" | "max";
|
||||
|
||||
export const MODEL_FORCED_EFFORT: Record<string, ChatGptThinkingEffort> = {
|
||||
"gpt-5-6-pro": "standard",
|
||||
"gpt-5.6-pro": "standard",
|
||||
"gpt-5-5-pro": "standard",
|
||||
"gpt-5-5-pro-extended": "extended",
|
||||
export const MODEL_FORCED_EFFORT: Record<string, ChatGptThinkingEffort | null> = {
|
||||
"gpt-5.6-sol-instant": null,
|
||||
"gpt-5.6-sol-medium": "standard",
|
||||
"gpt-5.6-sol-high": "extended",
|
||||
"gpt-5.6-sol-xhigh": "max",
|
||||
"gpt-5.6-sol-pro": "standard",
|
||||
"gpt-5.5-instant": null,
|
||||
"gpt-5.5-medium": "standard",
|
||||
"gpt-5.5-high": "extended",
|
||||
"gpt-5.5-xhigh": "max",
|
||||
"gpt-5.5-pro": "standard",
|
||||
"gpt-5.5-pro-extended": "extended",
|
||||
};
|
||||
|
||||
/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint
|
||||
* accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a
|
||||
* new thinking entry there automatically extends this set.
|
||||
*
|
||||
* Derived from MODEL_MAP keys (always dot-form) that contain "thinking" or
|
||||
* are the `o3` reasoning model; the values are the chatgpt.com-side slugs. */
|
||||
export const THINKING_CAPABLE_SLUGS: ReadonlySet<string> = new Set(
|
||||
Object.entries(MODEL_MAP)
|
||||
.filter(([k]) => k.includes("thinking") || k === "o3")
|
||||
.map(([, v]) => v)
|
||||
);
|
||||
const MODEL_SYSTEM_HINTS: Record<string, readonly string[]> = {
|
||||
// Captured from the Free-account Think toggle. ChatGPT sends this both at
|
||||
// the request root and on the user message metadata.
|
||||
"gpt-5.6-luna-free-thinking": ["reason"],
|
||||
};
|
||||
|
||||
/** chatgpt.com only exposes the thinking-effort toggle on dedicated thinking
|
||||
* models and the o-series. PATCHing for a non-thinking surface is a no-op
|
||||
* (the server accepts it but the routing-time read picks the wrong knob).
|
||||
*
|
||||
* The lookup also catches callers that pass a chatgpt.com slug directly as
|
||||
* the `model` field without MODEL_MAP translation. */
|
||||
export function isThinkingCapableModel(modelId: string, slug: string): boolean {
|
||||
return (
|
||||
modelId.includes("thinking") ||
|
||||
modelId === "o3" ||
|
||||
slug.includes("thinking") ||
|
||||
THINKING_CAPABLE_SLUGS.has(slug) ||
|
||||
THINKING_CAPABLE_SLUGS.has(modelId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Map either a chatgpt.com-native value (`standard`/`extended`/`max`) or the
|
||||
* OpenAI Chat Completions `reasoning_effort` field to the value the
|
||||
* `user_last_used_model_config` endpoint expects.
|
||||
*
|
||||
* minimal | low | medium | standard → standard
|
||||
* high | extended → extended
|
||||
* xhigh | max → max
|
||||
*
|
||||
* `xhigh` remains a compatibility alias for the highest ChatGPT Web tier.
|
||||
* Returns null for absent/unknown inputs. */
|
||||
export function normalizeThinkingEffort(input: unknown): ChatGptThinkingEffort | null {
|
||||
if (typeof input !== "string") return null;
|
||||
const v = input.trim().toLowerCase();
|
||||
if (v === "max" || v === "xhigh") return "max";
|
||||
if (v === "extended" || v === "high") return "extended";
|
||||
if (v === "standard" || v === "low" || v === "medium" || v === "minimal") {
|
||||
return "standard";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve the requested effort for this turn.
|
||||
* Order: `providerSpecificData.thinkingEffort` (raw override, takes native
|
||||
* `standard`/`extended`/`max` values) > `body.reasoning_effort` (top-level
|
||||
* OpenAI Chat Completions field) > `body.reasoning.effort` (Responses-API
|
||||
* nesting). Returns null when the caller did not request one. */
|
||||
export function resolveThinkingEffort(
|
||||
body: unknown,
|
||||
providerSpecificData: Record<string, unknown> | undefined
|
||||
): ChatGptThinkingEffort | null {
|
||||
if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) {
|
||||
return normalizeThinkingEffort(providerSpecificData.thinkingEffort);
|
||||
}
|
||||
const b = (body as Record<string, unknown> | null) ?? null;
|
||||
if (!b) return null;
|
||||
const top = normalizeThinkingEffort(b.reasoning_effort);
|
||||
if (top) return top;
|
||||
const nested = (b.reasoning as Record<string, unknown> | undefined)?.effort;
|
||||
return normalizeThinkingEffort(nested);
|
||||
export function resolveChatGptSystemHints(model: string): string[] {
|
||||
return [...(MODEL_SYSTEM_HINTS[model] ?? [])];
|
||||
}
|
||||
|
||||
export interface ResolvedChatGptModel {
|
||||
@@ -113,12 +67,16 @@ export interface ResolvedChatGptModel {
|
||||
|
||||
export function resolveChatGptModel(
|
||||
model: string,
|
||||
body: unknown,
|
||||
providerSpecificData: Record<string, unknown> | undefined
|
||||
_body?: unknown,
|
||||
_providerSpecificData?: Record<string, unknown>
|
||||
): ResolvedChatGptModel {
|
||||
const slug = MODEL_MAP[model] ?? model;
|
||||
const forcedEffort = MODEL_FORCED_EFFORT[model] ?? null;
|
||||
const effort = forcedEffort ?? resolveThinkingEffort(body, providerSpecificData);
|
||||
const isPro = slug === "gpt-5-6-pro" || slug === "gpt-5-5-pro";
|
||||
const effort = MODEL_FORCED_EFFORT[model] ?? null;
|
||||
const isPro =
|
||||
model === "gpt-5.6-sol-pro" ||
|
||||
model === "gpt-5.5-pro" ||
|
||||
model === "gpt-5.5-pro-extended" ||
|
||||
slug === "gpt-5-6-pro" ||
|
||||
slug === "gpt-5-5-pro";
|
||||
return { slug, effort, isPro };
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ import { errorResponse } from "../utils/error.ts";
|
||||
import { normalizeCodexResponsesInput } from "../utils/responsesInputNormalization.ts";
|
||||
import * as prl from "../utils/providerRequestLogging.ts";
|
||||
import { createRequire } from "module";
|
||||
// Quota parsing/scheduling extracted to a pure leaf; re-exported for external
|
||||
// importers (handlers/chatCore/codexQuota.ts + tests).
|
||||
// Quota parsing/scheduling extracted to a pure leaf; re-exported for the
|
||||
// Codex account module and tests.
|
||||
export {
|
||||
type CodexQuotaSnapshot,
|
||||
parseCodexQuotaHeaders,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./base.ts";
|
||||
import { FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { getAccessToken } from "../services/tokenRefresh.ts";
|
||||
import { isProbeContext } from "@/shared/utils/probeOrigin";
|
||||
import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts";
|
||||
import {
|
||||
buildStreamingResponse,
|
||||
@@ -208,9 +209,7 @@ function buildToolExchangePrompt(messages: OpenAIMessage[]): string {
|
||||
const line = renderConversationTurn(message, role, text);
|
||||
if (line) convo.push(line);
|
||||
}
|
||||
const header = systemParts.length
|
||||
? `System instructions:\n${systemParts.join("\n\n")}\n\n`
|
||||
: "";
|
||||
const header = systemParts.length ? `System instructions:\n${systemParts.join("\n\n")}\n\n` : "";
|
||||
const body = `${header}${convo.join(
|
||||
"\n\n"
|
||||
)}\n\nContinue the response using the tool result above; do not repeat the tool call.`.trim();
|
||||
@@ -672,7 +671,9 @@ export class GitlabExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
let activeCredentials = input.credentials;
|
||||
if (this.needsRefresh(activeCredentials)) {
|
||||
// Probe-origin dispatches must not consume a refresh-token rotation —
|
||||
// routing state untouched; mirrors the base.ts guard (#9817).
|
||||
if (!isProbeContext() && this.needsRefresh(activeCredentials)) {
|
||||
const refreshed = await this.refreshCredentials(activeCredentials, input.log || null);
|
||||
if (refreshed) {
|
||||
activeCredentials = mergeCredentials(activeCredentials, refreshed);
|
||||
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
GROK_BUILD_DEFAULT_REASONING_EFFORT,
|
||||
GROK_BUILD_REASONING_INCLUDE,
|
||||
GROK_BUILD_RESPONSES_URL,
|
||||
GROK_BUILD_SUPPORTED_REASONING_EFFORTS,
|
||||
GROK_BUILD_TOKEN_URL,
|
||||
} from "../config/grokBuild.ts";
|
||||
import { resolvePublicCred } from "../utils/publicCreds.ts";
|
||||
import { BaseExecutor, type ExecutorLog, type ProviderCredentials } from "./base.ts";
|
||||
|
||||
const GROK_BUILD_MAX_TOOLS = 200;
|
||||
const GROK_BUILD_SUPPORTED_REASONING_EFFORTS = new Set(["low", "medium", "high"]);
|
||||
const GROK_BUILD_REASONING_EFFORT_SET = new Set(GROK_BUILD_SUPPORTED_REASONING_EFFORTS);
|
||||
const GROK_BUILD_REFRESH_MAX_ATTEMPTS = 3;
|
||||
const GROK_BUILD_REFRESH_MIN_DELAY_MS = 200;
|
||||
const GROK_BUILD_TERMINAL_REFRESH_ERRORS = new Set(["invalid_grant", "invalid_client"]);
|
||||
@@ -33,7 +34,6 @@ const GROK_BUILD_UNSUPPORTED_PARAMS = [
|
||||
"reasoning_effort",
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* Grok Build's cli-chat-proxy is stricter about Responses `function_call_output.output`
|
||||
* than OpenAI's Responses API. Agent tool results can contain truncated / incomplete
|
||||
@@ -128,7 +128,7 @@ function normalizeGrokBuildReasoning(
|
||||
): Record<string, unknown> | null {
|
||||
const reasoning = asRequestRecord(value);
|
||||
const hasExplicitEffort = Object.prototype.hasOwnProperty.call(reasoning, "effort");
|
||||
if (!GROK_BUILD_SUPPORTED_REASONING_EFFORTS.has(String(reasoning.effort))) {
|
||||
if (!GROK_BUILD_REASONING_EFFORT_SET.has(String(reasoning.effort))) {
|
||||
delete reasoning.effort;
|
||||
}
|
||||
if (model === "grok-composer-2.5-fast") {
|
||||
|
||||
@@ -229,6 +229,16 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) {
|
||||
return audioStreamResponse(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice-note clients send response_format=ogg. OpenAI TTS documents opus, not ogg.
|
||||
* OmniRoute already returns Ogg/Opus bytes for opus — alias ogg → opus (#10587).
|
||||
*/
|
||||
export function normalizeSpeechResponseFormat(fmt) {
|
||||
if (typeof fmt !== "string" || !fmt) return "mp3";
|
||||
const lower = fmt.toLowerCase();
|
||||
return lower === "ogg" ? "opus" : lower;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Soniox TTS (OpenAI speech shape → Soniox /tts, returns raw audio bytes)
|
||||
*/
|
||||
@@ -963,7 +973,7 @@ export async function handleAudioSpeech({
|
||||
model: modelId,
|
||||
input: body.input,
|
||||
voice: body.voice || "alloy",
|
||||
response_format: body.response_format || "mp3",
|
||||
response_format: normalizeSpeechResponseFormat(body.response_format),
|
||||
speed: body.speed || 1.0,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -334,7 +334,7 @@ import {
|
||||
import { cacheReasoningFromAssistantMessage } from "../services/reasoningCache.ts";
|
||||
import { sanitizeOpenAITool } from "../services/toolSchemaSanitizer.ts";
|
||||
import { isCompactResponsesEndpoint } from "../executors/codex.ts";
|
||||
import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts";
|
||||
import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts";
|
||||
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
|
||||
import { translateNonStreamingResponse } from "./responseTranslator.ts";
|
||||
import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts";
|
||||
@@ -397,6 +397,7 @@ import {
|
||||
} from "../utils/aiSdkCompat.ts";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import { isLocalStreamLifecycleError } from "@/shared/utils/circuitBreaker";
|
||||
import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin";
|
||||
import { extractFacts } from "@/lib/memory/extraction";
|
||||
import { handleToolCallExecution } from "@/lib/skills/interception";
|
||||
import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers";
|
||||
@@ -656,40 +657,6 @@ export async function handleChatCore({
|
||||
creds: Record<string, unknown> | null | undefined,
|
||||
transport?: string
|
||||
): void => recordKeyHealthStatusFor(status, creds, log, transport);
|
||||
const persistCodexQuotaState = async (headers: Record<string, string> | null, status = 0) => {
|
||||
const currentConnectionId = getCurrentConnectionId();
|
||||
if (provider !== "codex" || !currentConnectionId || !headers) return;
|
||||
try {
|
||||
const existingProviderData =
|
||||
credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object"
|
||||
? (credentials.providerSpecificData as Record<string, unknown>)
|
||||
: {};
|
||||
// Pure payload build extracted to chatCore/codexQuota.ts (#3501). Returns null when the
|
||||
// response carries no quota headers (nothing to persist).
|
||||
const built = buildCodexQuotaPersistence({
|
||||
headers,
|
||||
existingProviderData,
|
||||
modelForScope: model || requestedModel || "",
|
||||
status,
|
||||
});
|
||||
if (!built) return;
|
||||
if (built.exhaustionLog) {
|
||||
log?.debug?.("CODEX", built.exhaustionLog);
|
||||
}
|
||||
// Invalidate the preflight cache for this connection so the next
|
||||
// isModelAvailable check fetches fresh quota data.
|
||||
if (status === 429) {
|
||||
invalidateCodexQuotaCache(currentConnectionId);
|
||||
}
|
||||
await updateProviderConnection(currentConnectionId, {
|
||||
providerSpecificData: built.nextProviderData,
|
||||
});
|
||||
credentials.providerSpecificData = built.nextProviderData;
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`);
|
||||
}
|
||||
};
|
||||
// ── Phase 9.2: Idempotency check ──
|
||||
// Resolve the idempotency key once here and reuse it at the Phase 9.2 save site below,
|
||||
// rather than re-deriving it. (#3821-review LEDGER-6)
|
||||
@@ -2973,7 +2940,18 @@ export async function handleChatCore({
|
||||
? (extractSessionAffinityKey(body, clientRawRequest?.headers) ?? null)
|
||||
: null;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
// ── Antigravity BYOP 422 account-rotation state ─────────────────────
|
||||
// A GCP_PROJECT_REQUIRED 422 is account-specific (that Google
|
||||
// account lacks a GCP Project ID). Rotate to a sibling antigravity
|
||||
// account instead of surfacing the error, so multi-account setups
|
||||
// keep working without user action. Tracked separately from
|
||||
// maxAttempts so non-BYOP antigravity failures never get a second
|
||||
// shot (no double upstream calls).
|
||||
const antigravityByopExcludedIds: string[] = [];
|
||||
let antigravityByopRotationPending = false;
|
||||
|
||||
while (attempts < maxAttempts || antigravityByopRotationPending) {
|
||||
antigravityByopRotationPending = false; // consumed per iteration
|
||||
trace("pre_executor", { attempt: attempts });
|
||||
updatePendingScope(pendingScope, {
|
||||
stage: "sending_to_provider",
|
||||
@@ -3056,6 +3034,33 @@ export async function handleChatCore({
|
||||
const res = normalizeExecutorResult(rawExecutorResult);
|
||||
trace("post_executor", { status: res?.response?.status });
|
||||
|
||||
if (
|
||||
provider === "codex" &&
|
||||
attemptConnectionId &&
|
||||
!(await shouldIsolateProbeFailures())
|
||||
) {
|
||||
try {
|
||||
const persistedQuota = await persistCodexChildQuotaResponse({
|
||||
connectionId: String(attemptConnectionId),
|
||||
model: modelToCall || model || requestedModel || "",
|
||||
headers: normalizeHeaders(res.response.headers),
|
||||
status: res.response.status,
|
||||
});
|
||||
if (persistedQuota) {
|
||||
execCreds.providerSpecificData = persistedQuota.providerSpecificData;
|
||||
if (persistedQuota.exhaustionLog) {
|
||||
log?.debug?.("CODEX", persistedQuota.exhaustionLog);
|
||||
}
|
||||
}
|
||||
if (res.response.status === 429) {
|
||||
invalidateCodexQuotaCache(String(attemptConnectionId));
|
||||
}
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Track Gemini RPM + RPD request counts for 429 classification
|
||||
if (provider === "gemini") {
|
||||
incrementRequestCount(modelToCall);
|
||||
@@ -3065,7 +3070,11 @@ export async function handleChatCore({
|
||||
stage: "provider_response_started",
|
||||
});
|
||||
|
||||
if (res.response.status === 401 && executionConnectionId) {
|
||||
if (
|
||||
res.response.status === 401 &&
|
||||
executionConnectionId &&
|
||||
!(await shouldIsolateProbeFailures())
|
||||
) {
|
||||
recordKeyHealthStatus(401, execCreds);
|
||||
}
|
||||
|
||||
@@ -3095,7 +3104,10 @@ export async function handleChatCore({
|
||||
!managedLease &&
|
||||
comboStrategy !== "context-relay" &&
|
||||
res.response.status === 429 &&
|
||||
attempts < maxAttempts - 1
|
||||
attempts < maxAttempts - 1 &&
|
||||
// Probe-origin (test-all) 429 must not rotate accounts or persist
|
||||
// cooldowns — routing state untouched (#9817).
|
||||
!(await shouldIsolateProbeFailures())
|
||||
) {
|
||||
const failedConnectionId =
|
||||
executionConnectionId || credentials?.connectionId || connectionId;
|
||||
@@ -3110,29 +3122,15 @@ export async function handleChatCore({
|
||||
`429 on connection ${String(failedConnectionId).slice(0, 8)} (attempt ${attempts + 1}/${maxAttempts}), rotating account`
|
||||
);
|
||||
|
||||
// Mark only the current Codex model scope as rate-limited.
|
||||
// Mark only the current Codex model scope as rate-limited. A connection-wide
|
||||
// cooldown here would let a Spark limit suppress independent Sol/Terra traffic.
|
||||
if (failedConnectionId) {
|
||||
await markCodexScopeRateLimited({
|
||||
failedConnectionId: String(failedConnectionId),
|
||||
model: modelToCall || model || requestedModel || null,
|
||||
rateLimitedUntil: new Date(Date.now() + (retryAfterMs || 60_000)).toISOString(),
|
||||
credentials,
|
||||
credentials: execCreds || credentials,
|
||||
});
|
||||
// Fix B: also persist the cooldown to
|
||||
// `provider_connections.rate_limited_until`. Without this,
|
||||
// the Codex 429 cascade survives the current request (via
|
||||
// `markCodexScopeRateLimited`'s in-memory Map) but is lost
|
||||
// on process restart — the same exhausted Codex key is
|
||||
// re-picked on the very next request. Mirrors
|
||||
// `open-sse/executors/antigravity.ts:343`.
|
||||
// Best-effort: never crash the chat path on DB write failure.
|
||||
try {
|
||||
const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
|
||||
const untilMs = Date.now() + (retryAfterMs || 60_000);
|
||||
setConnectionRateLimitUntil(String(failedConnectionId), untilMs);
|
||||
} catch {
|
||||
// ignore — best effort
|
||||
}
|
||||
if (!codexExcludedIds.includes(String(failedConnectionId))) {
|
||||
codexExcludedIds.push(String(failedConnectionId));
|
||||
}
|
||||
@@ -3200,6 +3198,55 @@ export async function handleChatCore({
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Antigravity BYOP 422 account rotation ───────────────────────
|
||||
// GCP_PROJECT_REQUIRED (422, code gcp_project_required) means
|
||||
// THIS Google account must Bring Its Own GCP Project. Mark the
|
||||
// connection excluded (rateLimitedUntil, best-effort) and rotate
|
||||
// to a sibling antigravity account so the request succeeds
|
||||
// without user action. When no sibling exists (or all are BYOP),
|
||||
// fall through: the error-state block excludes the connection
|
||||
// and the actionable 422 is surfaced.
|
||||
if (provider === "antigravity" && res.response.status === 422) {
|
||||
const byopBody = await res.response
|
||||
.clone()
|
||||
.text()
|
||||
.catch(() => "");
|
||||
if (byopBody.includes("gcp_project_required")) {
|
||||
const byopFailedId =
|
||||
executionConnectionId || credentials?.connectionId || connectionId;
|
||||
if (byopFailedId) {
|
||||
if (!antigravityByopExcludedIds.includes(String(byopFailedId))) {
|
||||
antigravityByopExcludedIds.push(String(byopFailedId));
|
||||
}
|
||||
try {
|
||||
const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
|
||||
setConnectionRateLimitUntil(
|
||||
String(byopFailedId),
|
||||
Date.now() + COOLDOWN_MS.gcpProjectRequired
|
||||
);
|
||||
} catch {
|
||||
// best-effort — never break the rotation path
|
||||
}
|
||||
}
|
||||
const byopNextCreds = await getProviderCredentials(
|
||||
"antigravity",
|
||||
null,
|
||||
null,
|
||||
modelToCall || model || requestedModel || null,
|
||||
{ excludeConnectionIds: [...antigravityByopExcludedIds] }
|
||||
).catch(() => null);
|
||||
if (byopNextCreds && !byopNextCreds.allRateLimited) {
|
||||
log?.warn?.(
|
||||
"ANTIGRAVITY_BYOP_ROTATION",
|
||||
`BYOP 422 on connection ${String(byopFailedId).slice(0, 8)} → rotating to ${String(byopNextCreds.connectionId).slice(0, 8)}`
|
||||
);
|
||||
Object.assign(credentials, byopNextCreds);
|
||||
antigravityByopRotationPending = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For streaming: release the semaphore when the client drains or cancels the stream.
|
||||
if (stream) {
|
||||
const originalBody = res.response.body;
|
||||
@@ -3714,10 +3761,15 @@ export async function handleChatCore({
|
||||
}
|
||||
|
||||
// Handle 401/403 - try token refresh using executor
|
||||
// T-PROBE: probe-origin failures never attempt the refresh — a probe must
|
||||
// not consume a rotating refresh token nor persist an "expired"
|
||||
// deactivation on refresh failure (#9817). The 401/403 then flows into
|
||||
// the normal providerFailure classification (record-only in probe mode).
|
||||
if (
|
||||
(providerResponse.status === HTTP_STATUS.UNAUTHORIZED ||
|
||||
providerResponse.status === HTTP_STATUS.FORBIDDEN) &&
|
||||
!hadStreamOptions // Skip refresh if failure may be from stream_options removal, not auth
|
||||
!hadStreamOptions && // Skip refresh if failure may be from stream_options removal, not auth
|
||||
!(await shouldIsolateProbeFailures())
|
||||
) {
|
||||
// Fix A: wrap refreshCredentials in runWithOnPersist so the persist callback
|
||||
// executes INSIDE the per-connection mutex held by getAccessToken. This makes
|
||||
@@ -3869,8 +3921,6 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
await persistCodexQuotaState(normalizeHeaders(providerResponse.headers), providerResponse.status);
|
||||
|
||||
// Check provider response - return error info for fallback handling
|
||||
providerFailure: if (!providerResponse.ok) {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
@@ -3999,17 +4049,34 @@ export async function handleChatCore({
|
||||
if (errorConnectionId && errorType) {
|
||||
try {
|
||||
if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) {
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
isActive: false,
|
||||
testStatus: "banned",
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} banned (${statusCode}) — disabling permanently`
|
||||
);
|
||||
// T-PROBE: a probe-origin failure (model test-all) must never
|
||||
// remove the connection from the pool — record but stay active.
|
||||
if (await shouldIsolateProbeFailures()) {
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
});
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active`
|
||||
);
|
||||
} else {
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
isActive: false,
|
||||
testStatus: "banned",
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} banned (${statusCode}) — disabling permanently`
|
||||
);
|
||||
}
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) {
|
||||
// T-PROBE: probe-origin failures (test-all) never deactivate —
|
||||
// record but stay active; Plan A (extra keys) stays first so the
|
||||
// real path keeps its existing priority (#9817).
|
||||
// Plan A: if connection has extra API keys, don't disable — only the failing key is affected.
|
||||
// Single-key connections still get disabled as before.
|
||||
if (
|
||||
@@ -4027,6 +4094,16 @@ export async function handleChatCore({
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — has extra keys, keeping connection active`
|
||||
);
|
||||
} else if (await shouldIsolateProbeFailures()) {
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
});
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active`
|
||||
);
|
||||
} else {
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
isActive: false,
|
||||
@@ -4040,73 +4117,90 @@ export async function handleChatCore({
|
||||
);
|
||||
}
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) {
|
||||
// Kimi's 403 says "billing cycle" for both an exhausted subscription and a
|
||||
// temporary request window. Read its official usage endpoint before making
|
||||
// the connection terminal: a non-zero Weekly quota plus an empty Ratelimit
|
||||
// window must recover automatically at the reported reset time.
|
||||
let kimiRateLimitResetAt: string | null = null;
|
||||
if (provider === "kimi-coding") {
|
||||
try {
|
||||
const { fetchAndPersistProviderLimits } = await import("@/lib/usage/providerLimits");
|
||||
const { usage } = await fetchAndPersistProviderLimits(errorConnectionId, "manual");
|
||||
kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage);
|
||||
} catch {
|
||||
// Preserve the existing quota handling when Kimi's usage endpoint is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
// Providers with per-model quotas — lock the model only, not the connection
|
||||
const quotaCooldownMs = kimiRateLimitResetAt
|
||||
? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0)
|
||||
: retryAfterMs || COOLDOWN_MS.rateLimit;
|
||||
const accountSemaphoreKey = resolveAccountSemaphoreKey({
|
||||
provider,
|
||||
model: currentModel,
|
||||
connectionId: errorConnectionId,
|
||||
credentials,
|
||||
});
|
||||
if (accountSemaphoreKey) {
|
||||
markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs);
|
||||
}
|
||||
if (kimiRateLimitResetAt) {
|
||||
// T-PROBE: probe-origin failures never write quota state —
|
||||
// `testStatus: "credits_exhausted"` is terminal and removes the
|
||||
// connection from the pool; semaphore locks and per-model quota
|
||||
// lockouts are routing mutations too. Record only (#9817).
|
||||
if (await shouldIsolateProbeFailures()) {
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
testStatus: "unavailable",
|
||||
rateLimitedUntil: kimiRateLimitResetAt,
|
||||
backoffLevel: 0,
|
||||
lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}`
|
||||
);
|
||||
} else if (isModelScope() && errorConnectionId) {
|
||||
const lockFn = provider === "antigravity" ? lockExactModel : lockModel;
|
||||
lockFn(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs);
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)`
|
||||
);
|
||||
} else if (
|
||||
lockModelIfPerModelQuota(
|
||||
provider,
|
||||
errorConnectionId,
|
||||
model,
|
||||
"quota_exhausted",
|
||||
quotaCooldownMs
|
||||
)
|
||||
) {
|
||||
const quotaScope = getQuotaScopeLabelForProvider(provider, model);
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)`
|
||||
);
|
||||
} else {
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
testStatus: "credits_exhausted",
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
});
|
||||
console.warn(`[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`);
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} probe ${errorType} (${statusCode}) — connection stays active`
|
||||
);
|
||||
} else {
|
||||
// Kimi's 403 says "billing cycle" for both an exhausted subscription and a
|
||||
// temporary request window. Read its official usage endpoint before making
|
||||
// the connection terminal: a non-zero Weekly quota plus an empty Ratelimit
|
||||
// window must recover automatically at the reported reset time.
|
||||
let kimiRateLimitResetAt: string | null = null;
|
||||
if (provider === "kimi-coding") {
|
||||
try {
|
||||
const { fetchAndPersistProviderLimits } =
|
||||
await import("@/lib/usage/providerLimits");
|
||||
const { usage } = await fetchAndPersistProviderLimits(errorConnectionId, "manual");
|
||||
kimiRateLimitResetAt = getKimiTemporaryRateLimitResetAt(usage);
|
||||
} catch {
|
||||
// Preserve the existing quota handling when Kimi's usage endpoint is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
// Providers with per-model quotas — lock the model only, not the connection
|
||||
const quotaCooldownMs = kimiRateLimitResetAt
|
||||
? Math.max(new Date(kimiRateLimitResetAt).getTime() - Date.now(), 0)
|
||||
: retryAfterMs || COOLDOWN_MS.rateLimit;
|
||||
const accountSemaphoreKey = resolveAccountSemaphoreKey({
|
||||
provider,
|
||||
model: currentModel,
|
||||
connectionId: errorConnectionId,
|
||||
credentials,
|
||||
});
|
||||
if (accountSemaphoreKey) {
|
||||
markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs);
|
||||
}
|
||||
if (kimiRateLimitResetAt) {
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
testStatus: "unavailable",
|
||||
rateLimitedUntil: kimiRateLimitResetAt,
|
||||
backoffLevel: 0,
|
||||
lastErrorType: PROVIDER_ERROR_TYPES.RATE_LIMITED,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} Kimi request window exhausted (${statusCode}) — retrying after ${kimiRateLimitResetAt}`
|
||||
);
|
||||
} else if (isModelScope() && errorConnectionId) {
|
||||
const lockFn = provider === "antigravity" ? lockExactModel : lockModel;
|
||||
lockFn(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs);
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)`
|
||||
);
|
||||
} else if (
|
||||
lockModelIfPerModelQuota(
|
||||
provider,
|
||||
errorConnectionId,
|
||||
model,
|
||||
"quota_exhausted",
|
||||
quotaCooldownMs
|
||||
)
|
||||
) {
|
||||
const quotaScope = getQuotaScopeLabelForProvider(provider, model);
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)`
|
||||
);
|
||||
} else {
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
testStatus: "credits_exhausted",
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(`[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`);
|
||||
}
|
||||
}
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) {
|
||||
// Normal 401 (token/session auth issue): keep account active for refresh/re-auth.
|
||||
@@ -4148,31 +4242,60 @@ export async function handleChatCore({
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
try {
|
||||
const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
|
||||
setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs);
|
||||
} catch {
|
||||
// DB write failure must never break the fallback loop
|
||||
// T-PROBE: the 24h exclusion is a routing mutation — a probe must
|
||||
// not push a connection into a day-long cooldown (#9817).
|
||||
if (!(await shouldIsolateProbeFailures())) {
|
||||
try {
|
||||
const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
|
||||
setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs);
|
||||
} catch {
|
||||
// DB write failure must never break the fallback loop
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} geo-blocked (${statusCode}) — excluded for ${Math.ceil(geoCooldownMs / 1000)}s, trying other accounts`
|
||||
);
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.GCP_PROJECT_REQUIRED) {
|
||||
// Antigravity BYOP: the account must Bring Its Own GCP Project.
|
||||
// Account-specific and fixable by entering a Project ID — never a
|
||||
// model lockout, never a ban. Exclude the connection for the
|
||||
// cooldown window so selection prefers sibling accounts; the 422
|
||||
// body carries the actionable message when no sibling is available.
|
||||
const byopCooldownMs = COOLDOWN_MS.gcpProjectRequired ?? 24 * 60 * 60 * 1000;
|
||||
await updateProviderConnection(errorConnectionId, {
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
try {
|
||||
const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
|
||||
setConnectionRateLimitUntil(errorConnectionId, Date.now() + byopCooldownMs);
|
||||
} catch {
|
||||
// best-effort — never break the error path
|
||||
}
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} GCP project required (${statusCode}) — excluded for ${Math.ceil(byopCooldownMs / 1000)}s, routing to other accounts (enter a Project ID to restore)`
|
||||
);
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) {
|
||||
// 404 — model/endpoint does not exist upstream. Lock the model so the
|
||||
// retry/backoff loop stops hammering the dead endpoint (which would
|
||||
// otherwise degenerate into a 429 rate-limit storm). Connection stays
|
||||
// active since only the specific model is unavailable. (#6827)
|
||||
const notFoundCooldownMs = COOLDOWN_MS.notFound;
|
||||
lockModel(
|
||||
provider,
|
||||
errorConnectionId,
|
||||
currentModel,
|
||||
"model_not_found",
|
||||
notFoundCooldownMs
|
||||
);
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)`
|
||||
);
|
||||
// T-PROBE: the model lockout is a routing mutation — a probe must
|
||||
// not lock a model for the cooldown window (#9817).
|
||||
if (!(await shouldIsolateProbeFailures())) {
|
||||
lockModel(
|
||||
provider,
|
||||
errorConnectionId,
|
||||
currentModel,
|
||||
"model_not_found",
|
||||
notFoundCooldownMs
|
||||
);
|
||||
console.warn(
|
||||
`[provider] Node ${errorConnectionId} model not found (${statusCode}) for ${currentModel} - locking model for ${Math.ceil(notFoundCooldownMs / 1000)}s (connection stays active)`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort state update; request flow should continue with fallback handling.
|
||||
|
||||
@@ -1,42 +1,29 @@
|
||||
import { getCodexModelScope } from "../../config/codexQuotaScopes.ts";
|
||||
import { updateProviderConnection } from "@/lib/db/providers";
|
||||
import { getCachedProviderConnectionById } from "@/lib/localDb";
|
||||
import { persistCodexChildCooldown } from "../../services/codexAccount/index.ts";
|
||||
|
||||
type CodexFailoverCredentials = {
|
||||
connectionId?: string | null;
|
||||
providerSpecificData?: unknown;
|
||||
};
|
||||
|
||||
function asProviderData(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export async function markCodexScopeRateLimited(params: {
|
||||
failedConnectionId: string;
|
||||
model: string | null;
|
||||
rateLimitedUntil: string;
|
||||
credentials?: CodexFailoverCredentials | null;
|
||||
}): Promise<void> {
|
||||
const connection = await getCachedProviderConnectionById(params.failedConnectionId).catch(() => null);
|
||||
const existingProviderData = connection
|
||||
? asProviderData(connection.providerSpecificData)
|
||||
: asProviderData(params.credentials?.providerSpecificData);
|
||||
const existingScopeMap = asProviderData(existingProviderData.codexScopeRateLimitedUntil);
|
||||
const nextProviderData = {
|
||||
...existingProviderData,
|
||||
codexScopeRateLimitedUntil: {
|
||||
...existingScopeMap,
|
||||
[getCodexModelScope(params.model || "")]: params.rateLimitedUntil,
|
||||
},
|
||||
};
|
||||
const persisted = params.model
|
||||
? await persistCodexChildCooldown({
|
||||
connectionId: params.failedConnectionId,
|
||||
model: params.model,
|
||||
rateLimitedUntil: params.rateLimitedUntil,
|
||||
}).catch(() => null)
|
||||
: null;
|
||||
|
||||
updateProviderConnection(params.failedConnectionId, {
|
||||
...(connection ? { providerSpecificData: nextProviderData } : {}),
|
||||
lastError: "429 rate limited — codex account rotation",
|
||||
errorCode: 429,
|
||||
}).catch(() => {});
|
||||
|
||||
if (params.credentials && String(params.credentials.connectionId) === params.failedConnectionId) {
|
||||
params.credentials.providerSpecificData = nextProviderData;
|
||||
if (
|
||||
persisted &&
|
||||
params.credentials &&
|
||||
String(params.credentials.connectionId) === params.failedConnectionId
|
||||
) {
|
||||
params.credentials.providerSpecificData = persisted.providerSpecificData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* chatCore Codex quota-persistence builder (Quality Gate v2 / Fase 9 — chatCore god-file
|
||||
* decomposition, #3501).
|
||||
*
|
||||
* Pure core of handleChatCore's persistCodexQuotaState: turns the upstream Codex quota response
|
||||
* headers into the next `providerSpecificData` payload (the codexQuotaState snapshot, plus — on a
|
||||
* 429 whose dual-window usage is past the exhaustion threshold — the per-scope cooldown timestamp,
|
||||
* the exhausted window, and the debug-log message). The handler keeps the impure parts byte-
|
||||
* identically: the DB write (updateProviderConnection), the preflight-cache invalidation on every
|
||||
* 429, the credentials mutation, and emitting the returned log line.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseCodexQuotaHeaders,
|
||||
getCodexModelScope,
|
||||
getCodexDualWindowCooldownMs,
|
||||
} from "../../executors/codex.ts";
|
||||
|
||||
export type CodexQuotaPersistence = {
|
||||
/** The merged providerSpecificData to persist (existing data + codexQuotaState [+ 429 cooldown]). */
|
||||
nextProviderData: Record<string, unknown>;
|
||||
/** The CODEX debug-log message to emit when a 429 exhausted a window, else null. */
|
||||
exhaustionLog: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the providerSpecificData update for a Codex quota response. Returns null when the response
|
||||
* carries no quota headers (nothing to persist). Pure: a function of the headers, the existing
|
||||
* provider data, the model used for scope resolution, and the upstream status.
|
||||
*/
|
||||
export function buildCodexQuotaPersistence(opts: {
|
||||
headers: Record<string, string>;
|
||||
existingProviderData: Record<string, unknown>;
|
||||
modelForScope: string;
|
||||
status: number;
|
||||
}): CodexQuotaPersistence | null {
|
||||
const { headers, existingProviderData, modelForScope, status } = opts;
|
||||
|
||||
const quota = parseCodexQuotaHeaders(headers);
|
||||
if (!quota) return null;
|
||||
|
||||
const scope = getCodexModelScope(modelForScope);
|
||||
const quotaState = {
|
||||
usage5h: quota.usage5h,
|
||||
limit5h: quota.limit5h,
|
||||
resetAt5h: quota.resetAt5h,
|
||||
usage7d: quota.usage7d,
|
||||
limit7d: quota.limit7d,
|
||||
resetAt7d: quota.resetAt7d,
|
||||
scope,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const nextProviderData: Record<string, unknown> = {
|
||||
...existingProviderData,
|
||||
codexQuotaState: quotaState,
|
||||
};
|
||||
|
||||
let exhaustionLog: string | null = null;
|
||||
|
||||
// T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking.
|
||||
// Use dual-window cooldown to distinguish short-term and weekly Codex exhaustion.
|
||||
if (status === 429) {
|
||||
const { cooldownMs, window: exhaustedWindow } = getCodexDualWindowCooldownMs(quota);
|
||||
if (cooldownMs > 0) {
|
||||
const scopeUntil = new Date(Date.now() + cooldownMs).toISOString();
|
||||
const scopeMapRaw =
|
||||
existingProviderData &&
|
||||
typeof existingProviderData === "object" &&
|
||||
existingProviderData.codexScopeRateLimitedUntil &&
|
||||
typeof existingProviderData.codexScopeRateLimitedUntil === "object"
|
||||
? existingProviderData.codexScopeRateLimitedUntil
|
||||
: {};
|
||||
|
||||
nextProviderData.codexScopeRateLimitedUntil = {
|
||||
...(scopeMapRaw as Record<string, unknown>),
|
||||
[scope]: scopeUntil,
|
||||
};
|
||||
nextProviderData.codexExhaustedWindow = exhaustedWindow;
|
||||
exhaustionLog = `Quota exhaustion on ${exhaustedWindow} window, cooldown until ${scopeUntil}`;
|
||||
}
|
||||
}
|
||||
|
||||
return { nextProviderData, exhaustionLog };
|
||||
}
|
||||