Merge remote-tracking branch 'origin/release/v3.8.50' into refactor/executor-registry

This commit is contained in:
Markus Hartung
2026-08-18 12:01:59 -03:00
757 changed files with 48256 additions and 8009 deletions

View File

@@ -246,6 +246,11 @@ OMNIROUTE_USE_TURBOPACK=1
# hints in production logs.
# OMNIROUTE_PROXY_FETCH_DEBUG=true
# Set to "true" or "1" to include client/egress IPs and the account prefix in
# the verbose `[ProxyEgress]` process-log line (src/lib/proxyLogger.ts). Kept
# OFF by default so the process log does not leak IPs or the account prefix.
# PROXY_LOG_INCLUDE_IPS=true
# Set to any non-empty value to emit `[omniroute completion]` diagnostics from
# the CLI shell-completion cache paths (read/refresh/write) in
# bin/cli/commands/completion.mjs. Off by default — these caches fail silently
@@ -370,6 +375,16 @@ ALLOW_API_KEY_REVEAL=false
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
# Maximum heavyweight requests simultaneously admitted in one process. Default 1.
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for the structural admission gate
# (#10183, #10268): a second concurrent heavyweight request past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
# is only shed with a retryable 503 when the heap is ALSO under this much pressure — on a
# healthy heap it is admitted instead. Range (0, 1]. Default 0.75.
# OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO=0.75
# Bounded extra capacity for the healthy-heap fast path above OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
# (#10437): once this many concurrent leases are active through the healthy-heap bypass,
# further busy requests fall through to the same bounded-wait/shed path used under real heap
# pressure. 0 disables the bypass entirely. Default 1.
# OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM=1
# Message count that classifies an otherwise small body as heavyweight. Default 200.
# OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT=200
# Tool count that classifies an otherwise small body as heavyweight. Default 64.
@@ -393,6 +408,12 @@ ALLOW_API_KEY_REVEAL=false
# OMNIROUTE_CHAT_VIRTUAL_TTL_MS=60000
# Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). Default 64.
# OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS=64
# Adaptive runtime virtual admission lanes (#9654): master switch for the per-tenant
# adaptive gate (system 2, open-sse/services/admission). NOTE: the TTL/MAX_SESSIONS
# vars above tune the byte-level per-connection lanes (system 1); this switch enables
# the adaptive runtime lanes. Dashboard feature flag of the same name; env wins over
# the dashboard override; restart required. Default: off.
# OMNIROUTE_CHAT_VIRTUAL_LANES=1
# Hard cap (bytes) for a non-streaming upstream response buffered fully into memory
# (#5152). Past this the upstream reader is cancelled and the request fails fast
@@ -742,6 +763,9 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_CONTINUE_BIN=cn
# CLI_QODER_BIN=qoder
# CLI_QWEN_BIN=qwen
# CLI_AIDER_BIN=aider
# CLI_GOOSE_BIN=goose
# CLI_GEMINI_BIN=gemini
# CLI_AUGGIE_BIN=auggie
# AUGGIE_BIN=auggie
@@ -800,6 +824,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode).
# OMNIROUTE_CONTEXT=
# Disable the optional OS keychain backend for CLI remote-context credentials.
# When enabled, context tokens stay in config.json with mode 0600 and the CLI
# prints a one-time fallback warning. Useful for deliberate headless/container
# operation; leave unset to use keytar when the native backend is available.
# Used by: bin/cli/contexts.mjs.
# OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED=0
# Enforce scope-based access control on MCP tool calls.
# Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes.
# OMNIROUTE_MCP_ENFORCE_SCOPES=false
@@ -1253,6 +1284,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
# hatches that are referenced in code today.
# DEEPSEEK_API_KEY=
# NVIDIA_API_KEY=
# Jina Foundation API + Reader fallback when no dashboard jina-ai / jina-reader
# connection exists. Dashboard keys always win (fill-first).
# JINA_AI_API_KEY=
# JINA_API_KEY=
# Gemini / Google AI Studio embeddings fallback when no dashboard gemini
# connection exists. Dashboard keys always win (fill-first).
# GEMINI_API_KEY=
# GOOGLE_API_KEY=
# Windsurf / Devin CLI direct API key.
# Used by: open-sse/executors/devin-cli.ts — bypasses OAuth when set.
@@ -1648,6 +1687,16 @@ APP_LOG_TO_FILE=true
# Used by: src/shared/constants/featureFlagDefinitions.ts, src/lib/arenaEloSync.ts
# ARENA_ELO_SYNC_ENABLED=true
# How model ids are prefixed in GET /v1/models. "dual" (default) advertises BOTH the
# short alias prefix and the canonical provider prefix for each model (cc/claude-sonnet-4-6
# AND claude/claude-sonnet-4-6) so client configs that hardcoded either form keep working —
# which roughly doubles the catalog. "alias" emits one id per model; "canonical" emits only
# the full provider-id prefix (and drops providers whose alias is already canonical).
# A client can override per request with GET /v1/models?prefix=alias instead.
# Also configurable from Dashboard > Settings > Feature Flags.
# Used by: src/shared/constants/featureFlagDefinitions.ts, src/app/api/v1/models/catalog.ts
# MODELS_CATALOG_PREFIX_MODE=dual
# Sync interval in seconds. Default: 86400 (24 hours).
# ARENA_ELO_SYNC_INTERVAL=86400
@@ -1756,6 +1805,12 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/executors/cloudflare-ai.ts
# CLOUDFLARE_ACCOUNT_ID=
# ── Cloudflare AI Playground ──
# Full desktop Chrome binary path, used when Playwright's bundled Chromium is
# blocked by the headless fingerprint check.
# Used by: open-sse/executors/cloudflare-playground.ts
# CLOUDFLARE_PLAYGROUND_CHROME_PATH=
# ── Deno Deploy proxy relay (#4643 / 9router#1437) ──
# Override the Deno Deploy REST API base used by the proxy-pool relay deployer.
# Default: https://api.deno.com/v2 (omit unless mocking).
@@ -2796,3 +2851,13 @@ QUOTA_STORE_DRIVER=sqlite
# Spokesperson (Faro) base URL for the dashboard chat proxy (/api/conductor/ask).
# Used by: src/lib/conductor/faroProxy.ts
# CONDUCTOR_SPOKESPERSON_URL=http://127.0.0.1:7920
# ═══════════════════════════════════════════════════════════════════════════════
# QUOTA-AWARE PROVIDER SCHEDULING (opt-in, Phase 2)
# ═══════════════════════════════════════════════════════════════════════════════
# When enabled, routing skips connections whose configured per-window token
# budget (rateLimitOverrides.tpm) cannot afford the estimated request cost —
# before dispatching — instead of waiting for a 429. Fail-open: connections
# without a configured budget are always considered affordable. Requires the
# provider_quota_state table (migration 148).
# OMNIROUTE_QUOTA_AWARE_ROUTING=0

3
.gitignore vendored
View File

@@ -288,3 +288,6 @@ docker-compose.yml.bak
# CLI local cache/state
.playwright-cli
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
/.sandbox/

View File

@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 341 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 340 LLM providers, auto-fallback.
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (150 migrations) |
| Database | `src/lib/db/` | SQLite domain modules (153 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 109 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
@@ -433,6 +433,7 @@ For any non-trivial change, read the matching deep-dive first:
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
| Tunnels | `docs/ops/TUNNELS_GUIDE.md` |
| Electron desktop app | `docs/guides/ELECTRON_GUIDE.md` |
| VS Code Copilot Chat (OmniCopilot extension) | `docs/guides/VSCODE-COPILOT.md` |
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
| Quality gates (~80 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |

View File

@@ -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 → 341 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 1595% tokens (~89% avg) — never hit limits. 341 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 → 340 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 1595% tokens (~89% avg) — never hit limits. 340 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
</div>
@@ -63,7 +63,7 @@
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
| ------------------------- | :-----: | :---------: | :---------: |
| 🌐 Providers | 290 | **341** | more queued |
| 🌐 Providers | 290 | **340** | more queued |
| 🧠 Documented models | 1185 | **1202** | — |
| 🖼️ Modality Bridge | — | 🆕 vision | video |
| 📡 Radar free catalog | — | 🆕 opt-in | — |
@@ -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="#-341-ai-providers--90-free">🌐 Providers</a></td>
<td align="center"><a href="#-340-ai-providers--90-free">🌐 Providers</a></td>
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI &amp; 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. 341 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 341 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~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. 340 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 340 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 1595%, ~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: 341 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&apos;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: 340 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&apos;s docs."/>
<sub>📊 Full methodology &amp; per-feature detail vs 9router, OpenRouter, CLIProxyAPI &amp; LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
@@ -548,7 +548,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
- **🗜️ Compression hardening** — default-on inflation guard, Caveman packs for DE / FR / JA + Chinese (wényán), RTK filters for Gradle & .NET. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
- **💸 Honest flat-rate cost** — subscription / coding-plan providers read **$0** in cost analytics; budget, quota & routing keep estimating. → [API Reference](docs/reference/API_REFERENCE.md)
- **⚖️ Quota-Share routing** — split a shared account's quota fairly across pooled keys, work-conserving so idle slices are lent out. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute launch` / `launch-codex` are zero-config. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute run` launches 7 CLIs (Claude Code, Codex, Aider, Goose, OpenCode, Qwen Code, Gemini CLI) with zero config written; `omniroute configure` is an interactive provider+model picker with per-context favorites. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
- **🛰️ Remote mode** — drive a remote OmniRoute with scoped tokens (`connect` / `contexts` / `tokens`) + an `antigravity` OAuth helper for VPS installs. → [Remote Mode](docs/guides/REMOTE-MODE.md)
- **🧭 Smarter auto-routing** — `auto/<category>:<tier>` combos, **Fusion** (model panel + judge), task-aware routing, per-request model / mode / USD-budget overrides. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
- **🗜️ Pluggable compression** — 12 composable engines + Compression Studios: LLMLingua-2, two-tier Ultra, omniglyph, per-step fidelity gate, GCF v3.2, drag-reorder editor. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
@@ -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, Google Imagen, 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 **341-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 **340-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)
@@ -618,13 +618,35 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
<br/>
**Launch any supported CLI through OmniRoute in one command** — no config files written,
credentials injected per process, Qwen/Gemini get a throwaway isolated home:
```bash
omniroute run claude --model openai/gpt-5.4 # Claude Code
omniroute run codex --model glm/glm-5.2 # OpenAI Codex CLI
omniroute run aider --model glm/glm-5.2 -- --message "reply OK"
omniroute run goose --model glm/glm-5.2
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "reply OK"
# Or pick provider+model interactively and write the tool's own config:
omniroute configure codex # also: claude opencode qwen aider goose cline continue kilo
```
Every command honors the active remote context (`omniroute connect <host>`), `--dry-run`
previews the exact env/args without executing, and `--api-key-env NAME` keeps secrets out
of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<br/>
<div align="center">
## 🌐 341 AI Providers — 90+ Free
## 🌐 340 AI Providers — 90+ Free
</div>
> The most complete catalog of any open-source router: **341 providers**, **90+ with a free tier**, **56 free forever**.
> The most complete catalog of any open-source router: **340 providers**, **90+ with a free tier**, **56 free forever**.
<div align="center">
@@ -737,6 +759,8 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl
— works the same way on both stores. Source, issues and the publishing runbook live at
[diegosouzapw/OmniCopilot](https://github.com/diegosouzapw/OmniCopilot).
<sub>📖 [VS Code Copilot Chat guide](docs/guides/VSCODE-COPILOT.md) — setup, what the picker shows, dashboard-in-a-tab, troubleshooting</sub>
<br/>
<div align="center">
@@ -1148,7 +1172,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>&gt;=22.22.2 &lt;23 || &gt;=24.0.0 &lt;27</code></td></tr>
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 117 domain modules, 150 migrations</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 153 migrations</td></tr>
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>

View File

@@ -1,6 +1,6 @@
import { setTimeout as sleep } from "node:timers/promises";
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";
import { resolveActiveContext } from "./contexts.mjs";
import { resolveActiveContext, resolveActiveContextAsync } from "./contexts.mjs";
export const RETRY_DEFAULTS = Object.freeze({
maxAttempts: 3,
@@ -77,7 +77,7 @@ export async function buildHeaders(opts) {
let auth = explicitKey;
if (!auth) {
try {
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
const ctx = await resolveActiveContextAsync(opts.context ?? process.env.OMNIROUTE_CONTEXT);
auth = ctx?.accessToken || ctx?.apiKey || null;
} catch {
// No context credential available — fall through to the ambient fallback.

138
bin/cli/cli-manifest.mjs Normal file
View File

@@ -0,0 +1,138 @@
/**
* Canonical executable manifest for the OmniRoute CLI command surfaces.
*
* One entry per canonical target id. `run.mjs`, `configure.mjs` and
* `completion.mjs` derive their target lists, alias resolution and model-flag
* wiring from this table instead of keeping private copies, so a new target
* (or a renamed alias) is declared exactly once.
*
* The server-side runtime catalog (`src/shared/services/cliRuntime.ts`) stays
* the source of truth for binaries, config paths and health checks; the drift
* test `tests/unit/cli/cli-manifest-drift.test.ts` asserts the two worlds and
* every consumer surface stay in sync.
*
* Capability semantics:
* - `run`: launchable through `omniroute run <target>`.
* - `configure`: supported by the `omniroute configure <target>` picker.
* - `runModel`: how `run` injects `--model` for the target (`null` when the
* model travels via env/provider args instead of a CLI flag).
*/
export const CLI_TARGET_MANIFEST = Object.freeze({
claude: Object.freeze({
description: "Claude Code",
aliases: Object.freeze(["claude-code", "cc", "anthropic"]),
run: true,
configure: true,
runModel: null, // injected via ANTHROPIC_MODEL env by the launcher
}),
codex: Object.freeze({
description: "OpenAI Codex CLI",
aliases: Object.freeze(["codex-cli", "openai-codex", "openai"]),
run: true,
configure: true,
runModel: null, // injected via -c model_providers.omniroute.* args
}),
aider: Object.freeze({
description: "Aider",
aliases: Object.freeze([]),
run: true,
configure: true,
runModel: Object.freeze({ flag: "--model", prefix: "openai/" }),
}),
goose: Object.freeze({
description: "Goose",
aliases: Object.freeze(["goose-cli"]),
run: true,
configure: true,
runModel: null, // injected via GOOSE_MODEL env
}),
opencode: Object.freeze({
description: "OpenCode",
aliases: Object.freeze(["open-code"]),
run: true,
configure: true,
runModel: Object.freeze({ flag: "--model", prefix: "omniroute/" }),
}),
qwen: Object.freeze({
description: "Qwen Code",
aliases: Object.freeze(["qwen-code"]),
run: true,
configure: true,
runModel: Object.freeze({ flag: "--model", prefix: "", required: true }),
}),
gemini: Object.freeze({
// Launch contract verified against @google/gemini-cli 0.50.0:
// GOOGLE_GEMINI_BASE_URL points the SDK at OmniRoute's /v1beta surface,
// GEMINI_API_KEY + isolated GEMINI_CLI_HOME (settings selectedType
// "gemini-api-key") force API-key auth over any stored OAuth session.
description: "Google Gemini CLI",
aliases: Object.freeze(["gemini-cli"]),
run: true,
configure: false,
runModel: Object.freeze({ flag: "--model", prefix: "" }),
}),
cline: Object.freeze({
description: "Cline",
aliases: Object.freeze([]),
run: false,
configure: true,
runModel: null,
}),
continue: Object.freeze({
description: "Continue",
aliases: Object.freeze(["cn"]),
run: false,
configure: true,
runModel: null,
}),
kilo: Object.freeze({
description: "Kilo Code",
aliases: Object.freeze(["kilocode", "kilo-code", "kilo_cli"]),
run: false,
configure: true,
runModel: null,
}),
});
/**
* List canonical target ids, optionally filtered by capability
* (`"run"` or `"configure"`). Order follows manifest declaration order.
*/
export function listManifestTargets(capability) {
return Object.entries(CLI_TARGET_MANIFEST)
.filter(([, entry]) => !capability || entry[capability])
.map(([id]) => id);
}
/**
* Resolve a user-supplied target (canonical id or alias) to its canonical id.
* Returns `undefined` when the target is unknown or lacks the capability.
*/
export function resolveManifestTarget(rawTarget, capability) {
const normalized = String(rawTarget || "")
.trim()
.toLowerCase();
if (!normalized) return undefined;
for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) {
if (id === normalized || entry.aliases.includes(normalized)) {
if (capability && !entry[capability]) return undefined;
return id;
}
}
return undefined;
}
/** Model CLI-flag arguments for a `run` target, derived from the manifest. */
export function manifestModelArgs(targetId, model) {
if (!model) return [];
const spec = CLI_TARGET_MANIFEST[targetId]?.runModel;
if (!spec) return [];
const value = spec.prefix && !model.startsWith(spec.prefix) ? `${spec.prefix}${model}` : model;
return [spec.flag, value];
}
/** Whether a `run` target refuses to launch without an explicit model. */
export function manifestRequiresModel(targetId) {
return Boolean(CLI_TARGET_MANIFEST[targetId]?.runModel?.required);
}

View File

@@ -4,6 +4,12 @@ import { homedir } from "node:os";
import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { resolveDataDir } from "../data-dir.mjs";
import { listManifestTargets } from "../cli-manifest.mjs";
// Target lists shared with `omniroute run` / `omniroute configure` — always
// derived from the canonical manifest so the completion scripts cannot drift.
const RUN_TARGET_WORDS = listManifestTargets("run").join(" ");
const CONFIGURE_TARGET_WORDS = listManifestTargets("configure").join(" ");
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
@@ -129,6 +135,14 @@ _omniroute() {
'completion:Shell completion'
'memory:Manage memory store'
'skills:Manage skills'
'connect:Connect to a local or remote OmniRoute server'
'contexts:Manage local and remote server contexts'
'configure:Configure a supported AI CLI'
'launch:Launch an AI CLI through OmniRoute'
'launch-codex:Launch Codex through OmniRoute'
'run:Run a supported AI CLI through OmniRoute'
'runtime:Inspect CLI runtime capabilities'
'repair:Repair native runtime dependencies'
)
_arguments -C \\
@@ -153,7 +167,7 @@ _omniroute() {
local -a providers
providers=($(_omniroute_get_cache providers))
_describe 'provider' providers ;;
*) _arguments '1:subcommand:(list add remove test)' ;;
*) _arguments '1:subcommand:(available list test test-all validate rotate status add import auth remove edit metrics metric)' ;;
esac ;;
chat|stream)
_arguments \\
@@ -165,6 +179,12 @@ _omniroute() {
_arguments '1:resource:(combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience)' ;;
completion) _arguments '1:subcommand:(zsh bash fish install refresh)' ;;
config) _arguments '1:subcommand:(list get set validate contexts)' ;;
contexts) _arguments '1:subcommand:(list add use current show remove rename export import migrate)' ;;
configure) _arguments '1:target:(${CONFIGURE_TARGET_WORDS})' ;;
run) _arguments '1:target:(${RUN_TARGET_WORDS})' ;;
connect) _arguments '1:host:' ;;
launch|launch-codex) _arguments '--remote[Use a remote server]' '--context[Context name]:' '--model[Model ID]:' ;;
runtime) _arguments '1:subcommand:(check repair clean)' ;;
*) ;;
esac
case $state in
@@ -208,15 +228,19 @@ _omniroute() {
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills run"
cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex run runtime repair"
case "\${prev}" in
combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;;
keys) COMPREPLY=($(compgen -W "add list remove regenerate revoke reveal usage" -- "\${cur}")); return 0 ;;
providers) COMPREPLY=($(compgen -W "available list test test-all" -- "\${cur}")); return 0 ;;
providers) COMPREPLY=($(compgen -W "available list test test-all validate rotate status add import auth remove edit metrics metric" -- "\${cur}")); return 0 ;;
config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;;
completion) COMPREPLY=($(compgen -W "zsh bash fish install refresh" -- "\${cur}")); return 0 ;;
open) COMPREPLY=($(compgen -W "combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience" -- "\${cur}")); return 0 ;;
contexts) COMPREPLY=($(compgen -W "list add use current show remove rename export import migrate" -- "\${cur}")); return 0 ;;
configure) COMPREPLY=($(compgen -W "${CONFIGURE_TARGET_WORDS}" -- "\${cur}")); return 0 ;;
run) COMPREPLY=($(compgen -W "${RUN_TARGET_WORDS}" -- "\${cur}")); return 0 ;;
runtime) COMPREPLY=($(compgen -W "check repair clean" -- "\${cur}")); return 0 ;;
--model)
local models
models=$(_omniroute_get_cache models)
@@ -242,7 +266,7 @@ function generateFishScript() {
return `# OmniRoute CLI fish completion (dynamic)
complete -c omniroute -f
set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills update test run
set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex update test run runtime repair
for cmd in $commands
complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd
@@ -251,10 +275,14 @@ end
# Subcommands
complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch create delete show suggest'
complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove regenerate revoke reveal usage'
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all'
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all validate rotate status add import auth remove edit metrics metric'
complete -c omniroute -n '__fish_seen_subcommand_from config' -a 'list get set validate contexts'
complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish install refresh'
complete -c omniroute -n '__fish_seen_subcommand_from open' -a 'combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience'
complete -c omniroute -n '__fish_seen_subcommand_from contexts' -a 'list add use current show remove rename export import migrate'
complete -c omniroute -n '__fish_seen_subcommand_from configure' -a '${CONFIGURE_TARGET_WORDS}'
complete -c omniroute -n '__fish_seen_subcommand_from run' -a '${RUN_TARGET_WORDS}'
complete -c omniroute -n '__fish_seen_subcommand_from runtime' -a 'check repair clean'
# Dynamic completions from cache (requires python3)
function __omniroute_cache_get

View File

@@ -2,9 +2,17 @@ import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
import { apiFetch } from "../api.mjs";
import { loadContexts, resolveActiveContext } from "../contexts.mjs";
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
import { t } from "../i18n.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import {
getModelPreferenceState,
loadModelPreferences,
rankPreferredModels,
recordModelPreference,
} from "../model-preferences.mjs";
import { listManifestTargets, resolveManifestTarget } from "../cli-manifest.mjs";
/**
* `omniroute configure <cli>` — interactive provider+model picker that writes a
@@ -14,11 +22,80 @@ import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
* are in remote mode (`omniroute connect ...`) you pick from the remote server's
* live models and the profile is written on THIS machine.
*
* v1 targets the Codex CLI (writes ~/.codex/<name>.config.toml). The credential
* is referenced by env var (OMNIROUTE_API_KEY) — never written to disk.
* Codex keeps its profile-specific TOML files. Other targets delegate to their
* existing setup-* recipe after the same provider/model selection, so the
* picker remains a read-only orchestration layer and does not duplicate config
* merge logic.
*/
const SUPPORTED = ["codex"];
const SUPPORTED = listManifestTargets("configure");
export const SETUP_MODULES = {
claude: { module: "./setup-claude.mjs", exportName: "runSetupClaudeCommand" },
opencode: { module: "./setup-opencode.mjs", exportName: "runSetupOpencodeCommand" },
qwen: { module: "./setup-qwen.mjs", exportName: "runSetupQwenCommand" },
aider: { module: "./setup-aider.mjs", exportName: "runSetupAiderCommand" },
goose: { module: "./setup-goose.mjs", exportName: "runSetupGooseCommand" },
cline: { module: "./setup-cline.mjs", exportName: "runSetupClineCommand" },
continue: { module: "./setup-continue.mjs", exportName: "runSetupContinueCommand" },
kilo: { module: "./setup-kilo.mjs", exportName: "runSetupKiloCommand" },
};
/**
* Materialize the active server before delegating to a setup recipe.
*
* `apiFetch` knows how to prefer a named context over an ambient
* `OMNIROUTE_API_KEY`, but the older setup modules receive plain options and
* resolve those themselves. Passing the resolved URL/key here keeps the
* picker and the delegated recipe on the same local/remote target, including
* Claude Code which predates context-aware setup resolution.
*/
export function resolveConfigureTargetOptions(opts = {}) {
const resolved = { ...opts };
const ambientKey = process.env.OMNIROUTE_API_KEY || "";
const explicitRemote = opts.remote || opts.baseUrl;
let context;
try {
context = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
} catch {
// A missing/corrupt context file should retain the normal local fallback.
}
if (!explicitRemote) {
const localDefault = `http://localhost:${opts.port || process.env.PORT || "20128"}`;
const contextBase = String(context?.baseUrl || "").replace(/\/+$/, "");
if (contextBase && contextBase !== localDefault) {
resolved.remote = contextBase;
} else if (opts.port) {
resolved.remote = localDefault;
}
} else if (!resolved.remote && resolved.baseUrl) {
resolved.remote = resolved.baseUrl;
}
const contextKey = context?.accessToken || context?.apiKey;
if (contextKey && (!opts.apiKey || opts.apiKey === ambientKey)) {
resolved.apiKey = contextKey;
}
return resolved;
}
export function listConfigureTargets() {
return [...SUPPORTED];
}
export { getModelPreferenceState, rankPreferredModels };
function preferenceContextName(opts = {}) {
if (opts.context || process.env.OMNIROUTE_CONTEXT) {
return String(opts.context || process.env.OMNIROUTE_CONTEXT);
}
try {
return String(loadContexts().currentContext || "default");
} catch {
return "default";
}
}
/** Derive a short, filesystem-safe profile name from a model id. */
export function profileNameFromModel(modelId) {
@@ -80,8 +157,15 @@ async function configureCodex(modelId, ctxWindow, opts) {
toolLabel: "Codex",
hostCommand: "omniroute configure codex",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun: Boolean(opts.dryRun ?? opts["dry-run"]),
});
if (guard !== 0) return guard;
if (opts.dryRun ?? opts["dry-run"]) {
const profile = opts.name || profileNameFromModel(modelId);
const filePath = path.join(codexHome, `${profile}.config.toml`);
printInfo(`[dry-run] would write ${filePath}`);
return 0;
}
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
const profile = opts.name || profileNameFromModel(modelId);
const filePath = path.join(codexHome, `${profile}.config.toml`);
@@ -97,16 +181,22 @@ async function configureCodex(modelId, ctxWindow, opts) {
}
export async function runConfigureCommand(cli, opts = {}, cmd) {
const target = String(cli || "").toLowerCase();
if (!SUPPORTED.includes(target)) {
const target = resolveManifestTarget(cli, "configure");
if (!target) {
printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`);
return 2;
}
if (opts.favorite && opts.unfavorite) {
printError("Choose only one of --favorite or --unfavorite.");
return 2;
}
const globalOpts = cmd ? cmd.optsWithGlobals() : {};
const requestOpts = resolveConfigureTargetOptions({ ...globalOpts, ...opts });
const contextKey = preferenceContextName({ ...globalOpts, ...opts });
let models;
try {
models = await fetchModels(globalOpts);
models = await fetchModels(requestOpts);
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
return 1;
@@ -122,12 +212,15 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
chosenId = `${opts.provider}/${chosenId}`;
}
if (!chosenId) {
if (!chosenId && !opts.yes) {
const ids = models.map((m) => (typeof m === "string" ? m : m.id));
const preferences = loadModelPreferences();
const rankedIds = rankPreferredModels(target, ids, preferences, contextKey);
const preferenceState = getModelPreferenceState(target, preferences, contextKey);
const providers = [...new Set(models.map(providerOf))].sort();
const prompt = createPrompt();
try {
printHeading("Configure Codex CLI");
printHeading(`Configure ${target} CLI`);
let providerList = providers;
if (opts.provider) {
providerList = providers.filter((p) => p === opts.provider);
@@ -136,8 +229,18 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
const p = await prompt.ask("Provider");
if (p) providerList = providers.filter((x) => x === p);
}
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
const candidates = inProvider.length ? inProvider : ids;
const inProvider = rankedIds.filter((id) =>
providerList.includes(providerOf(byId(models, id)))
);
const candidates = inProvider.length ? inProvider : rankedIds;
if (preferenceState.favorites.length) {
printInfo(
`Favorites: ${preferenceState.favorites.filter((id) => ids.includes(id)).join(", ")}`
);
}
if (preferenceState.recent.length) {
printInfo(`Recent: ${preferenceState.recent.filter((id) => ids.includes(id)).join(", ")}`);
}
printInfo(
`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`
);
@@ -158,10 +261,48 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
}
const ctxWindow = contextWindowOf(entry);
let result;
if (target === "codex") {
return await configureCodex(chosenId, ctxWindow, opts);
result = await configureCodex(chosenId, ctxWindow, opts);
} else {
const setup = SETUP_MODULES[target];
if (!setup) {
printError(`No setup recipe is registered for '${target}'.`);
return 2;
}
try {
const module = await import(setup.module);
const runSetup = module[setup.exportName];
if (typeof runSetup !== "function") {
printError(`Setup recipe '${target}' is unavailable.`);
return 1;
}
const setupOpts = {
...requestOpts,
...opts,
model: chosenId,
// The picker already selected a model. Setup recipes that can generate
// a model subset receive an exact filter; the others use `model`.
...(target === "claude" || target === "continue" ? { only: chosenId } : {}),
yes: true,
};
result = await runSetup(setupOpts);
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
return 0;
if (result === 0 && !(opts.dryRun ?? opts["dry-run"])) {
recordModelPreference(target, chosenId, {
favorite: Boolean(opts.favorite),
unfavorite: Boolean(opts.unfavorite),
context: contextKey,
});
}
return result;
}
function byId(models, id) {
@@ -177,12 +318,20 @@ export function registerConfigure(program) {
.command("configure <cli>")
.description(
t("configure.description") ||
"Pick a provider+model from the active server and write a local CLI config (v1: codex)"
"Pick a provider+model from the active server and configure a supported local CLI"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL")
.option("--context <name>", "Named local/remote context")
.option("--api-key <key>", "OmniRoute API key (defaults to the active context/env)")
.option("--provider <id>", "Provider id (skips the interactive provider prompt)")
.option("--model <id>", "Model id (skips the interactive model prompt)")
.option("--name <name>", "Profile name to write (default: derived from model)")
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
.option("--yes", "Non-interactive; requires --model")
.option("--favorite", "Remember the selected model as a favorite for this CLI")
.option("--unfavorite", "Remove the selected model from this CLI's favorites")
.option("--dry-run", "Preview the generated config without writing")
.option(
"--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"

View File

@@ -1,5 +1,5 @@
import { apiFetch } from "../api.mjs";
import { loadContexts, saveContexts } from "../contexts.mjs";
import { loadContexts, saveContextsSecure } from "../contexts.mjs";
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
import { t } from "../i18n.mjs";
@@ -31,7 +31,9 @@ export function normalizeBaseUrl(host, port) {
/** Derive a clean context name from a host (strip scheme/port). */
export function hostLabel(host) {
let value = String(host || "").trim().replace(/^https?:\/\//i, "");
let value = String(host || "")
.trim()
.replace(/^https?:\/\//i, "");
value = value.split("/")[0].split(":")[0];
return value || "remote";
}
@@ -107,7 +109,7 @@ export async function runConnectCommand(host, opts = {}) {
description: `Remote OmniRoute (${host})`,
};
cfg.currentContext = name;
saveContexts(cfg);
await saveContextsSecure(cfg);
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
printInfo("All commands now target this server.");

View File

@@ -1,21 +1,34 @@
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs";
import {
loadContexts,
saveContextsSecure,
deleteContextCredential,
migrateContextCredentials,
resolveActiveContext,
} from "../contexts.mjs";
/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */
function authLabel(c) {
if (c?.accessToken) return "token";
if (c?.apiKey) return "key";
if (c?.credentialRef) return "keychain";
return "✗";
}
function contextMap(config) {
return config.contexts || config.profiles || {};
}
export async function confirm(msg) {
// Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking
// anyway leaves the readline question pending forever — Node then warns about an
// "unsettled top-level await" at exit. Decline cleanly instead and point at the
// non-interactive escape hatch so scripted callers fail safe rather than hang.
if (!process.stdin.isTTY) {
process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`);
process.stderr.write(
`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`
);
return false;
}
const readline = await import("node:readline");
@@ -31,6 +44,18 @@ function maskKey(k) {
return `${k.slice(0, 6)}***${k.slice(-4)}`;
}
/** Return an export-safe copy without legacy or canonical context credentials. */
export function redactContextSecrets(config) {
const out = JSON.parse(JSON.stringify(config || {}));
for (const collection of [out.contexts, out.profiles]) {
for (const context of Object.values(collection || {})) {
context.apiKey = null;
delete context.accessToken;
}
}
return out;
}
export function registerContexts(program) {
const ctx = program
.command("contexts")
@@ -43,7 +68,7 @@ export function registerContexts(program) {
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const cfg = loadContexts();
const rows = Object.entries(cfg.contexts || {}).map(([name, c]) => ({
const rows = Object.entries(contextMap(cfg)).map(([name, c]) => ({
active: name === (cfg.currentContext || "default") ? "●" : "",
name,
baseUrl: c.baseUrl || "",
@@ -73,7 +98,7 @@ export function registerContexts(program) {
.option("--description <d>", "Context description")
.action(async (name, opts) => {
const cfg = loadContexts();
if (cfg.contexts?.[name]) {
if (contextMap(cfg)[name]) {
process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`);
process.exit(2);
}
@@ -86,29 +111,29 @@ export function registerContexts(program) {
if (opts.accessTokenStdin) accessToken = value;
else apiKey = value;
}
cfg.contexts = cfg.contexts || {};
cfg.contexts[name] = {
const contexts = contextMap(cfg);
contexts[name] = {
baseUrl: opts.url,
accessToken: accessToken || undefined,
apiKey,
scope: opts.scope || undefined,
description: opts.description || undefined,
};
saveContexts(cfg);
await saveContextsSecure(cfg);
process.stdout.write(`Added context '${name}'\n`);
});
ctx
.command("use <name>")
.description("Switch active context")
.action((name) => {
.action(async (name) => {
const cfg = loadContexts();
if (!cfg.contexts?.[name]) {
if (!contextMap(cfg)[name]) {
process.stderr.write(`No such context: ${name}\n`);
process.exit(2);
}
cfg.currentContext = name;
saveContexts(cfg);
await saveContextsSecure(cfg);
process.stdout.write(`Active context: ${name}\n`);
});
@@ -143,7 +168,7 @@ export function registerContexts(program) {
.action((name, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const cfg = loadContexts();
const c = cfg.contexts?.[name];
const c = contextMap(cfg)[name];
if (!c) {
process.stderr.write(`No such context: ${name}\n`);
process.exit(2);
@@ -151,6 +176,8 @@ export function registerContexts(program) {
const display = {
name,
baseUrl: c.baseUrl,
auth: authLabel(c),
credentialRef: c.credentialRef || null,
accessToken: maskKey(c.accessToken),
apiKey: maskKey(c.apiKey),
scope: c.scope,
@@ -172,7 +199,7 @@ export function registerContexts(program) {
}
}
const cfg = loadContexts();
if (!cfg.contexts?.[name]) {
if (!contextMap(cfg)[name]) {
process.stderr.write(`No such context: ${name}\n`);
process.exit(2);
}
@@ -180,29 +207,37 @@ export function registerContexts(program) {
process.stderr.write("Cannot remove default context.\n");
process.exit(2);
}
delete cfg.contexts[name];
const contexts = contextMap(cfg);
const deletedCredential = await deleteContextCredential(name, contexts[name]);
if (contexts[name].credentialRef && !deletedCredential) {
process.stderr.write(
"Warning: could not remove the OS-keychain entry; the context reference was removed locally.\n"
);
}
delete contexts[name];
if (cfg.currentContext === name) cfg.currentContext = "default";
saveContexts(cfg);
await saveContextsSecure(cfg);
process.stdout.write(`Removed context '${name}'\n`);
});
ctx
.command("rename <old> <new>")
.description("Rename a context")
.action((oldName, newName) => {
.action(async (oldName, newName) => {
const cfg = loadContexts();
if (!cfg.contexts?.[oldName]) {
const contexts = contextMap(cfg);
if (!contexts[oldName]) {
process.stderr.write(`No such context: ${oldName}\n`);
process.exit(2);
}
if (cfg.contexts[newName]) {
if (contexts[newName]) {
process.stderr.write(`Context '${newName}' already exists.\n`);
process.exit(2);
}
cfg.contexts[newName] = cfg.contexts[oldName];
delete cfg.contexts[oldName];
contexts[newName] = contexts[oldName];
delete contexts[oldName];
if (cfg.currentContext === oldName) cfg.currentContext = newName;
saveContexts(cfg);
await saveContextsSecure(cfg);
process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`);
});
@@ -213,13 +248,7 @@ export function registerContexts(program) {
.option("--no-secrets", "Omit API keys from export")
.action(async (opts, cmd) => {
const cfg = loadContexts();
const out = JSON.parse(JSON.stringify(cfg));
if (opts.noSecrets) {
for (const c of Object.values(out.contexts || {})) {
c.apiKey = null;
delete c.accessToken;
}
}
const out = opts.noSecrets ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
const json = JSON.stringify(out, null, 2);
if (opts.out) {
const { writeFileSync } = await import("node:fs");
@@ -248,7 +277,12 @@ export function registerContexts(program) {
const cfg = opts.merge
? loadContexts()
: { version: 1, currentContext: "default", contexts: {} };
const incoming = imported.contexts || {};
if (!cfg.contexts && cfg.profiles) {
cfg.contexts = cfg.profiles;
delete cfg.profiles;
}
cfg.contexts = cfg.contexts || {};
const incoming = imported.contexts || imported.profiles || {};
let count = 0;
for (const [name, raw] of Object.entries(incoming)) {
if (typeof name !== "string" || !name) continue;
@@ -265,7 +299,38 @@ export function registerContexts(program) {
if (!opts.merge && typeof imported.currentContext === "string") {
cfg.currentContext = imported.currentContext;
}
saveContexts(cfg);
await saveContextsSecure(cfg);
process.stdout.write(`Imported ${count} context(s)\n`);
});
ctx
.command("migrate")
.description("Move legacy plaintext context credentials to the OS keychain")
.option("--yes", "Confirm migration in non-interactive scripts")
.action(async (opts) => {
const cfg = loadContexts();
const pending = Object.entries(cfg.contexts || cfg.profiles || {}).filter(
([, context]) => context?.accessToken || context?.apiKey
);
if (!pending.length) {
process.stdout.write("No plaintext context credentials found.\n");
return;
}
if (
!opts.yes &&
!(await confirm(`Migrate ${pending.length} context credential(s) to keychain?`))
) {
process.stdout.write("Cancelled.\n");
return;
}
const result = await migrateContextCredentials();
if (!result.migrated) {
process.stderr.write(
"OS keychain unavailable; credentials remain in config.json mode 0600.\n"
);
process.exitCode = 2;
return;
}
process.stdout.write(`Migrated ${pending.length} context credential(s) to keychain.\n`);
});
}

View File

@@ -229,18 +229,45 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
stdio: "inherit",
shell: shellValue,
});
let settled = false;
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
const signalHandlers = {};
const cleanupSignalHandlers = () => {
for (const signal of Object.keys(signalExitCode)) {
process.removeListener(signal, signalHandlers[signal]);
}
};
const finish = (code) => {
if (settled) return;
settled = true;
cleanupSignalHandlers();
resolve(code);
};
for (const signal of Object.keys(signalExitCode)) {
signalHandlers[signal] = () => {
try {
child.kill(signal);
} catch {
// The child may have already exited between the signal and cleanup.
}
finish(signalExitCode[signal]);
};
process.once(signal, signalHandlers[signal]);
}
child.on("error", (err) => {
if (err?.code === "ENOENT") {
console.error(
"The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex"
);
resolve(127);
finish(127);
} else {
console.error(String(err?.message || err));
resolve(1);
finish(1);
}
});
child.on("exit", (code) => resolve(code ?? 0));
child.on("exit", (code, signalName) => {
finish(code ?? signalExitCode[signalName] ?? 0);
});
});
}

View File

@@ -204,16 +204,43 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
shell,
...(process.platform === "win32" ? { windowsHide: true } : {}),
});
let settled = false;
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
const signalHandlers = {};
const cleanupSignalHandlers = () => {
for (const signal of Object.keys(signalExitCode)) {
process.removeListener(signal, signalHandlers[signal]);
}
};
const finish = (code) => {
if (settled) return;
settled = true;
cleanupSignalHandlers();
resolve(code);
};
for (const signal of Object.keys(signalExitCode)) {
signalHandlers[signal] = () => {
try {
child.kill(signal);
} catch {
// The child may have already exited between the signal and cleanup.
}
finish(signalExitCode[signal]);
};
process.once(signal, signalHandlers[signal]);
}
child.on("error", (err) => {
if (err && err.code === "ENOENT") {
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
resolve(127);
finish(127);
} else {
console.error(String(err?.message || err));
resolve(1);
finish(1);
}
});
child.on("exit", (code) => resolve(code ?? 0));
child.on("exit", (code, signalName) => {
finish(code ?? signalExitCode[signalName] ?? 0);
});
});
}

View File

@@ -54,11 +54,20 @@ async function openBrowser(url) {
}
}
async function pollStatus(endpoint, timeoutMs) {
function targetApiOptions(opts = {}) {
return {
baseUrl: opts.baseUrl,
context: opts.context,
apiKey: opts.apiKey,
timeout: opts.timeout,
};
}
async function pollStatus(endpoint, timeoutMs, opts = {}) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(2000);
const res = await apiFetch(endpoint);
const res = await apiFetch(endpoint, targetApiOptions(opts));
if (!res.ok) continue;
const data = await res.json();
if (data.status === "complete" || data.status === "completed") return data;
@@ -85,7 +94,7 @@ async function runBrowserFlow(def, opts) {
const authorizeUrl = `/api/oauth/${backendKey}/authorize${
redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""
}`;
const startRes = await apiFetch(authorizeUrl, { method: "GET" });
const startRes = await apiFetch(authorizeUrl, { ...targetApiOptions(opts), method: "GET" });
if (!startRes.ok) {
const detail = await safeErrorBody(startRes);
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`);
@@ -143,6 +152,7 @@ async function runBrowserFlow(def, opts) {
}
const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, {
...targetApiOptions(opts),
method: "POST",
body: {
code,
@@ -179,7 +189,7 @@ async function runImportFlow(def, opts) {
const endpoint = opts.importFromSystem
? `/api/oauth/${def.id}/auto-import`
: `/api/oauth/${def.id}/import`;
const res = await apiFetch(endpoint, { method: "POST" });
const res = await apiFetch(endpoint, { ...targetApiOptions(opts), method: "POST" });
if (!res.ok) {
process.stderr.write(`Import failed: ${res.status}\n`);
process.exit(1);
@@ -195,6 +205,7 @@ async function runSocialFlow(def, opts) {
process.exit(2);
}
const startRes = await apiFetch(`/api/oauth/${def.id}/social-authorize`, {
...targetApiOptions(opts),
method: "POST",
body: { social },
});
@@ -209,14 +220,18 @@ async function runSocialFlow(def, opts) {
process.stderr.write("Waiting for social authorization...\n");
const result = await pollStatus(
`/api/oauth/${def.id}/social-exchange?state=${encodeURIComponent(start.state ?? "")}`,
opts.timeout ?? 300000
opts.timeout ?? 300000,
opts
);
process.stdout.write(`Authorized: ${result.email ?? result.userId ?? "connected"}\n`);
}
async function runDeviceFlow(def, opts) {
const providerKey = resolveBackendKey(def.id);
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" });
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
...targetApiOptions(opts),
method: "POST",
});
if (!startRes.ok) {
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
process.exit(1);
@@ -233,12 +248,14 @@ async function runDeviceFlow(def, opts) {
while (Date.now() < deadline) {
await sleep(intervalMs);
const statusRes = await apiFetch(
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`,
targetApiOptions(opts)
);
if (!statusRes.ok) continue;
const status = await statusRes.json();
if (status.status === "complete" || status.status === "authorized") {
await apiFetch(`/api/providers/${providerKey}/auth/apply`, {
...targetApiOptions(opts),
method: "POST",
body: { state: start.state },
});
@@ -255,6 +272,7 @@ async function runDeviceFlow(def, opts) {
}
export async function runOAuthStart(opts, cmd) {
opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
const def = PROVIDERS_WITH_OAUTH.find((p) => p.id === opts.provider);
if (!def) {
process.stderr.write(
@@ -275,22 +293,23 @@ export async function runOAuthStart(opts, cmd) {
}
export async function runOAuthStatus(opts, cmd) {
const globalOpts = cmd.optsWithGlobals();
const globalOpts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
const params = new URLSearchParams();
if (opts.provider) params.set("provider", opts.provider);
const res = await apiFetch(`/api/providers?${params}`);
const res = await apiFetch(`/api/providers?${params}`, targetApiOptions(globalOpts));
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
const connections = (data.providers ?? data.items ?? data).filter(
const connections = (data.connections ?? data.providers ?? data.items ?? data).filter(
(c) => c.authType === "oauth" || c.authType === "oauth2"
);
emit(connections, globalOpts, connectionSchema);
}
export async function runOAuthRevoke(opts, cmd) {
opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
if (!opts.yes) {
process.stdout.write(
`Revoke OAuth for ${opts.provider}${opts.connectionId ? ` (${opts.connectionId})` : ""}? (yes/no) `
@@ -303,8 +322,11 @@ export async function runOAuthRevoke(opts, cmd) {
}
const id = opts.connectionId;
const res = id
? await apiFetch(`/api/providers/${id}`, { method: "DELETE" })
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, { method: "POST" });
? await apiFetch(`/api/providers/${id}`, { ...targetApiOptions(opts), method: "DELETE" })
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, {
...targetApiOptions(opts),
method: "POST",
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);

View File

@@ -13,6 +13,9 @@ export function registerProvider(program) {
omniroute providers test <name> — test a provider connection
omniroute providers test-all — test all active connections
omniroute providers validate — validate local configuration
omniroute providers add <id> — add an API-key connection
omniroute providers auth <id> — start an existing OAuth flow
omniroute providers remove <id> — remove a connection (requires confirmation)
`);
});
}

View File

@@ -0,0 +1,498 @@
import { readFileSync } from "node:fs";
import { apiFetch, statusToExitCode } from "../api.mjs";
import { createPrompt, printError, printInfo, printSuccess } from "../io.mjs";
import { runOAuthStart } from "./oauth.mjs";
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function isBlank(value) {
return value === undefined || value === null || String(value).trim() === "";
}
function credentialShape(value) {
if (isBlank(value)) return { present: false, length: 0 };
return { present: true, length: String(value).length };
}
const SENSITIVE_FIELD_RE =
/^(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|client[_-]?secret|credential|authorization)$/i;
/**
* Redact provider responses before they reach human or JSON output.
*
* The API normally masks credentials, but the CLI must remain safe when an
* operator enables a server-side reveal/debug option or when a compatible
* remote implementation returns a raw field. Presence and length are useful
* for diagnostics; the value itself must never be printed.
*/
export function redactProviderResponse(value, key = "") {
if (SENSITIVE_FIELD_RE.test(key)) {
if (value === null || value === undefined || value === "") return null;
return typeof value === "string" ? credentialShape(value) : "[redacted]";
}
if (Array.isArray(value)) return value.map((entry) => redactProviderResponse(entry));
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [
entryKey,
redactProviderResponse(entryValue, entryKey),
])
);
}
/**
* Extract a provider connection from the response returned by /api/providers.
* The server deliberately masks credentials, so this helper never needs to
* inspect or log a secret.
*/
export function findConnectionFromResponse(body, selector) {
const rows = Array.isArray(body?.connections)
? body.connections
: Array.isArray(body?.providers)
? body.providers
: Array.isArray(body)
? body
: [];
const needle = String(selector || "")
.trim()
.toLowerCase();
if (!needle) return null;
return (
rows.find((row) => String(row?.id || "").toLowerCase() === needle) ||
rows.find((row) =>
String(row?.id || "")
.toLowerCase()
.startsWith(needle)
) ||
rows.find((row) => String(row?.name || "").toLowerCase() === needle) ||
rows.find((row) => String(row?.provider || "").toLowerCase() === needle) ||
null
);
}
/** Build the API body without accepting management auth as a provider secret. */
export function buildProviderPayload(provider, opts = {}, credential) {
const body = {
provider: String(provider || "").trim(),
name: String(opts.name || provider || "").trim(),
};
if (!body.name) throw new Error("Provider name is required.");
if (!isBlank(credential)) body.apiKey = String(credential);
if (!isBlank(opts.defaultModel)) body.defaultModel = String(opts.defaultModel).trim();
if (!isBlank(opts.priority)) {
const priority = Number(opts.priority);
if (!Number.isInteger(priority) || priority < 1) {
throw new Error("--priority must be a positive integer.");
}
body.priority = priority;
}
if (opts.providerSpecificData) {
const raw = typeof opts.providerSpecificData === "string" ? opts.providerSpecificData : null;
try {
const parsed = raw ? JSON.parse(raw) : opts.providerSpecificData;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("must be a JSON object");
}
body.providerSpecificData = parsed;
} catch (error) {
throw new Error(
`--provider-specific-data must be a JSON object (${error instanceof Error ? error.message : String(error)})`
);
}
}
return body;
}
/** Resolve a credential from an explicit value, env reference, stdin, or prompt. */
export async function resolveProviderCredential(opts = {}, { prompt = true } = {}) {
// Commander represents the negated `--no-credential` option as
// `credential === false`. It is a control flag, never the literal provider
// credential "false".
if (opts.credential === false || opts.noCredential === true) return undefined;
if (!isBlank(opts.credential)) return String(opts.credential).trim();
const envName = String(opts.credentialEnv || opts["credential-env"] || "").trim();
if (envName) {
if (!ENV_NAME_RE.test(envName)) throw new Error("--credential-env must be a valid env name.");
const value = process.env[envName];
if (isBlank(value)) throw new Error(`Environment variable ${envName} is empty or unset.`);
return String(value).trim();
}
if (opts.credentialStdin || opts["credential-stdin"]) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const value = chunks.join("").trim();
if (!value) throw new Error("Credential stdin was empty.");
return value;
}
if (!prompt) return undefined;
const input = createPrompt();
try {
const value = await input.askSecret("Provider credential (hidden)");
const trimmed = String(value || "").trim();
if (!trimmed) throw new Error("Provider credential is required.");
return trimmed;
} finally {
input.close();
}
}
function targetOptions(opts = {}) {
return {
// Passing the global values through lets api.mjs apply its context-first
// auth precedence. A caller-supplied --base-url remains an explicit target.
baseUrl: opts.baseUrl,
context: opts.context,
apiKey: opts.apiKey,
timeout: opts.timeout,
};
}
async function readApiError(response) {
try {
const body = await response.json();
const message = body?.error?.message || body?.error || body?.message;
return message ? String(message) : `HTTP ${response.status}`;
} catch {
return `HTTP ${response.status}`;
}
}
async function listRemoteConnections(opts) {
return apiFetch("/api/providers?limit=5000", {
...targetOptions(opts),
acceptNotOk: true,
retry: false,
});
}
async function resolveRemoteConnection(selector, opts) {
const response = await listRemoteConnections(opts);
if (!response.ok) {
throw new Error(await readApiError(response));
}
const connection = findConnectionFromResponse(await response.json(), selector);
if (!connection) throw new Error(`Provider connection not found: ${selector}`);
return connection;
}
export async function runProviderAddCommand(provider, opts = {}) {
const normalized = String(provider || "").trim();
if (!normalized) {
printError("Provider id is required.");
return 2;
}
if (opts.oauth) {
if (opts.dryRun) {
if (!opts.silent) {
const preview = { action: "providers.auth", provider: normalized };
if (opts.json) console.log(JSON.stringify(preview, null, 2));
else printInfo(`dry-run: would start OAuth for ${normalized}`);
}
return 0;
}
return runOAuthStart({ ...opts, provider: normalized }, opts.command);
}
const allowNoCredential = Boolean(
opts.allowNoCredential || opts.noCredential || opts.credential === false
);
let credential;
try {
credential = await resolveProviderCredential(opts, {
prompt: !opts.dryRun && !opts.yes && !allowNoCredential,
});
if (!credential && !opts.dryRun && !allowNoCredential) {
throw new Error(
"Provider credential is required (use --credential-stdin or --credential-env)."
);
}
const payload = buildProviderPayload(normalized, opts, credential);
if (opts.dryRun) {
const preview = {
action: "providers.add",
provider: payload.provider,
name: payload.name,
defaultModel: payload.defaultModel || null,
credential: credentialShape(credential),
providerSpecificData: payload.providerSpecificData
? redactProviderResponse(payload.providerSpecificData)
: null,
};
if (!opts.silent) {
if (opts.json) console.log(JSON.stringify(preview, null, 2));
else printInfo(`dry-run: would add ${payload.provider}/${payload.name}`);
}
return 0;
}
const response = await apiFetch("/api/providers", {
...targetOptions(opts),
method: "POST",
body: payload,
acceptNotOk: true,
retry: false,
});
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const body = await response.json().catch(() => ({}));
if (!opts.silent) {
if (opts.json) console.log(JSON.stringify(redactProviderResponse(body), null, 2));
else printSuccess(`Added provider connection '${body?.connection?.name || payload.name}'.`);
}
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function runProviderImportCommand(file, opts = {}) {
let parsed;
try {
parsed = JSON.parse(readFileSync(file, "utf8"));
} catch (error) {
printError(
`Cannot read provider import file: ${error instanceof Error ? error.message : String(error)}`
);
return 1;
}
const entries = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.providers)
? parsed.providers
: [parsed];
if (!entries.length) {
printError("Provider import file contains no entries.");
return 2;
}
const results = [];
for (const entry of entries) {
if (!entry || typeof entry !== "object" || !entry.provider) {
results.push({ ok: false, error: "entry.provider is required" });
if (!opts.continueOnError) break;
continue;
}
const code = await runProviderAddCommand(entry.provider, {
...opts,
...entry,
credential: entry.apiKey ?? entry.credential,
dryRun: opts.dryRun,
yes: true,
silent: true,
allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential,
});
results.push({ provider: entry.provider, ok: code === 0, code });
if (code !== 0 && !opts.continueOnError) break;
}
if (opts.json) console.log(JSON.stringify({ file, results }, null, 2));
return results.every((result) => result.ok) ? 0 : 1;
}
async function confirmRemoval(label, opts) {
if (opts.yes) return true;
if (!process.stdin.isTTY) {
printError(`Removal of '${label}' declined on non-interactive stdin; pass --yes to confirm.`);
return false;
}
const prompt = createPrompt();
try {
const answer = await prompt.ask(`Remove provider connection '${label}'? [y/N] `);
return /^y(?:es)?$/i.test(String(answer || "").trim());
} finally {
prompt.close();
}
}
export async function runProviderRemoveCommand(selector, opts = {}) {
if (!selector) {
printError("Provider connection id, name, or provider is required.");
return 2;
}
try {
if (opts.dryRun) {
const connection = await resolveRemoteConnection(selector, opts);
if (opts.json) {
console.log(
JSON.stringify(
redactProviderResponse({ action: "providers.remove", connection }),
null,
2
)
);
} else printInfo(`dry-run: would remove ${connection.name || connection.id}`);
return 0;
}
const connection = await resolveRemoteConnection(selector, opts);
if (!(await confirmRemoval(connection.name || connection.id, opts))) return 0;
const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
...targetOptions(opts),
method: "DELETE",
acceptNotOk: true,
retry: false,
});
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
if (opts.json)
console.log(JSON.stringify(redactProviderResponse({ removed: connection }), null, 2));
else printSuccess(`Removed provider connection '${connection.name || connection.id}'.`);
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function runProviderEditCommand(selector, opts = {}) {
try {
const connection = await resolveRemoteConnection(selector, opts);
const body = {};
if (opts.name !== undefined) body.name = opts.name;
if (opts.defaultModel !== undefined) body.defaultModel = opts.defaultModel || null;
if (opts.priority !== undefined) body.priority = Number(opts.priority);
if (opts.active !== undefined) body.isActive = Boolean(opts.active);
if (opts.inactive !== undefined) body.isActive = false;
const credential = await resolveProviderCredential(opts, { prompt: false });
if (credential) body.apiKey = credential;
if (Object.keys(body).length === 0) {
printError(
"At least one edit field is required (--name, --default-model, --priority, --active/--inactive, or credential)."
);
return 2;
}
if (opts.dryRun) {
const preview = {
action: "providers.edit",
connection: redactProviderResponse(connection),
changes: { ...body, apiKey: credentialShape(body.apiKey) },
};
if (opts.json) console.log(JSON.stringify(preview, null, 2));
else printInfo(`dry-run: would edit ${connection.name || connection.id}`);
return 0;
}
const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, {
...targetOptions(opts),
method: "PUT",
body,
acceptNotOk: true,
retry: false,
});
if (!response.ok) {
printError(await readApiError(response));
return statusToExitCode(response.status);
}
const result = await response.json().catch(() => ({}));
if (opts.json) console.log(JSON.stringify(redactProviderResponse(result), null, 2));
else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`);
return 0;
} catch (error) {
printError(error instanceof Error ? error.message : String(error));
return 1;
}
}
export async function runProviderAuthCommand(provider, opts = {}, cmd) {
return runOAuthStart({ ...opts, provider }, cmd);
}
export function registerProviderCrud(providers) {
providers
.command("add <provider>")
.description("Add an API-key provider connection through the active local/remote server")
.option("--name <name>", "Connection name (defaults to provider id)")
.option(
"--credential <key>",
"Provider credential (prefer --credential-stdin or --credential-env)"
)
.option("--credential-env <name>", "Read provider credential from an environment variable")
.option("--credential-stdin", "Read provider credential from stdin")
.option("--allow-no-credential", "Allow providers whose catalog marks the credential optional")
.option("--no-credential", "Allow providers whose catalog marks the credential optional")
.option("--default-model <id>", "Default model for this connection")
.option("--priority <n>", "Connection priority", Number)
.option("--provider-specific-data <json>", "Provider-specific settings as a JSON object")
.option("--oauth", "Start the provider's existing OAuth flow instead")
.option("--yes", "Do not prompt for a credential")
.option("--dry-run", "Preview the request without writing")
.option("--json", "Print machine-readable output")
.action(async (provider, opts, cmd) => {
const code = await runProviderAddCommand(provider, {
...cmd.parent.optsWithGlobals(),
...opts,
command: cmd,
});
if (code !== 0) process.exit(code);
});
providers
.command("import <file>")
.description("Import provider connections from a JSON file")
.option("--continue-on-error", "Continue importing after a failed entry")
.option("--dry-run", "Preview requests without writing")
.option("--json", "Print machine-readable output")
.action(async (file, opts, cmd) => {
const code = await runProviderImportCommand(file, {
...cmd.parent.optsWithGlobals(),
...opts,
});
if (code !== 0) process.exit(code);
});
providers
.command("auth <provider>")
.description("Start an existing OAuth flow for a provider")
.option("--no-browser", "Print the authorization URL instead of opening a browser")
.option("--import-from-system", "Import credentials from the local system when supported")
.option("--social <provider>", "Use a social-login flow when supported")
.option("--timeout <ms>", "OAuth timeout", Number, 300000)
.action(async (provider, opts, cmd) => {
const code = await runProviderAuthCommand(
provider,
{ ...cmd.parent.optsWithGlobals(), ...opts },
cmd
);
if (code !== 0) process.exit(code);
});
providers
.command("remove <idOrName>")
.description("Remove one provider connection from the active local/remote server")
.option("--yes", "Confirm removal")
.option("--dry-run", "Preview the removal without writing")
.option("--json", "Print machine-readable output")
.action(async (idOrName, opts, cmd) => {
const code = await runProviderRemoveCommand(idOrName, {
...cmd.parent.optsWithGlobals(),
...opts,
});
if (code !== 0) process.exit(code);
});
providers
.command("edit <idOrName>")
.description("Edit one provider connection on the active local/remote server")
.option("--name <name>", "New connection name")
.option("--default-model <id>", "New default model")
.option("--priority <n>", "New connection priority", Number)
.option("--active", "Activate the connection")
.option("--inactive", "Deactivate the connection")
.option("--credential <key>", "Replace provider credential")
.option("--credential-env <name>", "Read replacement credential from an environment variable")
.option("--credential-stdin", "Read replacement credential from stdin")
.option("--dry-run", "Preview the edit without writing")
.option("--json", "Print machine-readable output")
.action(async (idOrName, opts, cmd) => {
const code = await runProviderEditCommand(idOrName, {
...cmd.parent.optsWithGlobals(),
...opts,
});
if (code !== 0) process.exit(code);
});
}

View File

@@ -13,6 +13,7 @@ import {
import { encryptCredential } from "../encryption.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { t } from "../i18n.mjs";
import { registerProviderCrud } from "./provider-crud.mjs";
function publicConnection(connection) {
return {
@@ -604,6 +605,8 @@ export function registerProviders(program) {
if (exitCode !== 0) process.exit(exitCode);
});
registerProviderCrud(providers);
extendProvidersMetrics(providers);
}

View File

@@ -2,7 +2,7 @@ import { apiFetch, isServerUp } from "../api.mjs";
import { t } from "../i18n.mjs";
export function registerQuota(program) {
program
const quota = program
.command("quota")
.description(t("quota.description"))
.option("--provider <id>", "Filter by provider")
@@ -12,6 +12,60 @@ export function registerQuota(program) {
const exitCode = await runQuotaCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
quota
.command("status")
.description("Show truthful OmniRoute gateway, quota, pool, and circuit state")
.action(async (opts, cmd) => runBoundedJson("/api/omniroute/status", cmd.optsWithGlobals()));
quota
.command("preview")
.description("Preview allocation enforcement without an upstream request")
.requiredOption("--api-key-id <id>", "API key id")
.requiredOption("--pool-id <id>", "quota pool id")
.option("--tokens <n>", "estimated token usage")
.action(async (opts, cmd) => {
const params = new URLSearchParams({ apiKeyId: opts.apiKeyId, poolId: opts.poolId });
if (opts.tokens != null) params.set("estimatedTokens", opts.tokens);
await runBoundedJson(`/api/quota/preview?${params}`, cmd.optsWithGlobals());
});
quota
.command("ensure <json>")
.description("Idempotently create or update a quota pool from a JSON object")
.action(async (json, opts, cmd) => {
let body;
try {
body = JSON.parse(json);
} catch {
console.error("Invalid pool JSON");
process.exit(2);
}
await runBoundedJson("/api/quota/pools?ensure=true", cmd.optsWithGlobals(), {
method: "POST",
body,
});
});
}
async function runBoundedJson(path, opts, request = {}) {
const started = performance.now();
const res = await apiFetch(path, {
...request,
retry: false,
timeout: Math.min(opts.timeout ?? 5000, 5000),
acceptNotOk: true,
});
const elapsed = Math.round(performance.now() - started);
if (process.env.OMNIROUTE_DEBUG === "1") {
console.error(`[omniroute] ${request.method ?? "GET"} ${path} completed in ${elapsed}ms`);
}
const payload = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
if (!res.ok) {
console.error(JSON.stringify(payload));
process.exit(res.exitCode ?? 1);
}
console.log(JSON.stringify(payload, null, 2));
}
export async function runQuotaCommand(opts = {}) {

View File

@@ -16,28 +16,16 @@ import {
import { t } from "../i18n.mjs";
import os from "node:os";
import { join } from "node:path";
import { spawn, execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { resolveActiveContext } from "../contexts.mjs";
const RUN_TARGETS = {
claude: {
aliases: ["claude", "claude-code", "cc"],
description: "Claude Code",
},
codex: {
aliases: ["codex", "openai-codex", "openai"],
description: "OpenAI Codex CLI",
},
};
/** @type {Record<string,string>} */
const RUN_TARGET_ALIAS_TO_CANONICAL = {
claude: "claude",
"claude-code": "claude",
cc: "claude",
codex: "codex",
"openai-codex": "codex",
openai: "codex",
};
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
import {
listManifestTargets,
manifestModelArgs,
manifestRequiresModel,
resolveManifestTarget,
} from "../cli-manifest.mjs";
function isBlank(value) {
return value === undefined || value === null || String(value).trim() === "";
@@ -48,6 +36,11 @@ function toAuthSource(targetOpts) {
!isBlank(targetOpts.token) || !isBlank(targetOpts.apiKey) || !isBlank(targetOpts["api-key"]);
if (explicit) return "option";
const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName) && !isBlank(process.env[envName])) {
return "env";
}
try {
const context = resolveActiveContext(targetOpts.context || process.env.OMNIROUTE_CONTEXT);
if (context && (context.accessToken || context.apiKey)) return "context";
@@ -60,16 +53,23 @@ function toAuthSource(targetOpts) {
return "none";
}
/** Resolve supported target to canonical id. */
/** Resolve a token option without ever printing its value in a plan. */
function resolveAuthTokenOption(targetOpts = {}) {
const direct = targetOpts.token || targetOpts.apiKey || targetOpts["api-key"];
if (!isBlank(direct)) return direct;
const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) return process.env[envName];
return undefined;
}
/** Resolve supported target (id or alias) to canonical id via the manifest. */
export function resolveRunTarget(target) {
const raw = String(target || "")
.trim()
.toLowerCase();
return RUN_TARGET_ALIAS_TO_CANONICAL[raw];
return resolveManifestTarget(target, "run");
}
export function listRunTargets() {
return Object.keys(RUN_TARGETS);
return listManifestTargets("run");
}
/**
@@ -116,8 +116,8 @@ async function buildClaudePlan(rawOpts, args = []) {
const merged = {
...rawOpts,
model,
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"],
apiKey: resolveAuthTokenOption(rawOpts),
token: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile ?? rawOpts.p,
};
@@ -142,7 +142,7 @@ async function buildClaudePlan(rawOpts, args = []) {
args: quotedArgs,
model: merged.model || undefined,
envDiff: envPreview(process.env, env),
authSource: toAuthSource(merged),
authSource: toAuthSource(rawOpts),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
};
}
@@ -151,7 +151,7 @@ async function buildCodexPlan(rawOpts, args = []) {
const model = resolveModelFromTargetOptions(rawOpts);
const merged = {
...rawOpts,
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
apiKey: resolveAuthTokenOption(rawOpts),
model,
profile: rawOpts.profile ?? rawOpts.p,
};
@@ -174,25 +174,303 @@ async function buildCodexPlan(rawOpts, args = []) {
args: quotedArgs,
model: merged.model || undefined,
envDiff: envPreview(process.env, env),
authSource: toAuthSource(merged),
authSource: toAuthSource(rawOpts),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
providerArgs,
profileArgs,
};
}
const NO_AUTH_SENTINEL = "omniroute-no-auth";
function resolveGenericSpawn(command) {
if (process.platform !== "win32") return { command, shell: undefined };
try {
const output = execFileSync("where.exe", [command], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
timeout: 3000,
windowsHide: true,
});
const matches = output
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean);
const preferred = matches.find((value) => /\.exe$/i.test(value));
if (preferred) return { command: preferred, shell: undefined };
const shim = matches.find((value) => /\.(?:cmd|bat)$/i.test(value));
if (shim) return { command: shim, shell: true };
} catch {
// Fall through to the conventional npm shim.
}
return { command: `${command}.cmd`, shell: true };
}
function genericEnv(baseEnv, kind, baseUrl, authToken, model) {
const env = { ...baseEnv };
for (const key of Object.keys(env)) {
if (kind === "aider" && /^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key)) {
delete env[key];
}
if (
kind === "goose" &&
(/^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key) || key.startsWith("GOOSE_"))
) {
delete env[key];
}
if (kind === "opencode" && key === "OPENCODE_CONFIG_CONTENT") delete env[key];
if (kind === "qwen" && (key === "QWEN_HOME" || key === "OMNIROUTE_API_KEY")) {
delete env[key];
}
if (
kind === "gemini" &&
/^(GOOGLE_GEMINI_BASE_URL|GEMINI_API_KEY|GOOGLE_API_KEY|GEMINI_CLI_HOME|GEMINI_DEFAULT_AUTH_TYPE|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_GCA)$/.test(
key
)
) {
delete env[key];
}
}
const token = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL;
if (kind === "aider") {
env.OPENAI_API_BASE = baseUrl;
env.OPENAI_API_KEY = token;
} else if (kind === "goose") {
env.GOOSE_PROVIDER = "openai";
env.OPENAI_HOST = baseUrl;
env.OPENAI_API_KEY = token;
if (model) env.GOOSE_MODEL = model;
} else if (kind === "opencode") {
env.OMNIROUTE_API_KEY = token;
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
omniroute: {
npm: "@ai-sdk/openai-compatible",
name: "OmniRoute",
options: {
baseURL: ensureV1BaseUrl(baseUrl),
apiKey: "{env:OMNIROUTE_API_KEY}",
},
...(model ? { models: { [model]: { name: model } } } : {}),
},
},
});
} else if (kind === "qwen") {
env.OMNIROUTE_API_KEY = token;
} else if (kind === "gemini") {
// Verified against @google/gemini-cli 0.50.0: the SDK appends
// /v1beta/models/<model>:generateContent to this base URL, which is
// OmniRoute's native Gemini surface. Auth is the API-key path; the
// isolated GEMINI_CLI_HOME (set at spawn time) keeps any stored OAuth
// session from overriding it.
env.GOOGLE_GEMINI_BASE_URL = baseUrl;
env.GEMINI_API_KEY = token;
env.GEMINI_DEFAULT_AUTH_TYPE = "gemini-api-key";
}
return env;
}
function ensureV1BaseUrl(baseUrl) {
const normalized = String(baseUrl || "").replace(/\/+$/, "");
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
}
function modelArgsForTarget(target, model) {
return manifestModelArgs(target, model);
}
function buildGeminiSettings() {
// Force API-key auth in the isolated home so the operator's stored OAuth
// session (Code Assist) never leaks into an OmniRoute-directed launch.
return JSON.stringify({ security: { auth: { selectedType: "gemini-api-key" } } }, null, 2);
}
function buildQwenSettings(baseUrl, model) {
const qwenBaseUrl = ensureV1BaseUrl(baseUrl);
return JSON.stringify(
{
modelProviders: {
openai: [
{
id: model,
name: `${model} (OmniRoute)`,
envKey: "OMNIROUTE_API_KEY",
baseUrl: qwenBaseUrl,
},
],
},
security: { auth: { selectedType: "openai" } },
model: { name: model, baseUrl: qwenBaseUrl },
},
null,
2
);
}
async function buildGenericPlan(target, rawOpts, args = []) {
const { baseUrl, authToken } = resolveLaunchTarget({
...rawOpts,
apiKey: resolveAuthTokenOption(rawOpts),
});
const commandSpec = resolveGenericSpawn(target);
const model = resolveModelFromTargetOptions(rawOpts);
if (manifestRequiresModel(target) && !model) {
throw new Error("Qwen Code requires --model in non-interactive OmniRoute launches");
}
const modelArgs = modelArgsForTarget(target, model);
const fullArgs = [...modelArgs, ...args];
const env = genericEnv(process.env, target, baseUrl, authToken, model);
return {
target,
baseUrl,
command: commandSpec.command,
shell: commandSpec.shell,
args: quoteShellArgs(fullArgs, process.platform),
model: model || undefined,
envDiff: envPreview(process.env, env),
authSource: toAuthSource(rawOpts),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
modelArgs,
configOverlay:
target === "qwen"
? "temporary QWEN_HOME (removed after exit)"
: target === "gemini"
? "temporary GEMINI_CLI_HOME (removed after exit)"
: target === "opencode"
? "OPENCODE_CONFIG_CONTENT (process environment only)"
: undefined,
};
}
async function healthCheckForRun(baseUrl) {
try {
const response = await fetch(`${baseUrl}/api/monitoring/health`, {
signal: AbortSignal.timeout(3000),
});
return response.ok;
} catch {
return false;
}
}
async function runGenericTarget(target, rawOpts, args) {
const { baseUrl, authToken } = resolveLaunchTarget({
...rawOpts,
apiKey: resolveAuthTokenOption(rawOpts),
});
if (!(await healthCheckForRun(baseUrl))) {
console.error(`OmniRoute is not reachable at ${baseUrl}. Start it or check --remote.`);
return 1;
}
const model = resolveModelFromTargetOptions(rawOpts);
if (manifestRequiresModel(target) && !model) {
console.error("Qwen Code requires --model in non-interactive OmniRoute launches.");
return 2;
}
const modelArgs = modelArgsForTarget(target, model);
const commandSpec = resolveGenericSpawn(target);
const childEnv = genericEnv(process.env, target, baseUrl, authToken, model);
let overlayHome;
if (target === "qwen") {
overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-qwen-run-"));
writeFileSync(join(overlayHome, "settings.json"), buildQwenSettings(baseUrl, model), {
encoding: "utf8",
mode: 0o600,
});
childEnv.QWEN_HOME = overlayHome;
} else if (target === "gemini") {
overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-gemini-run-"));
mkdirSync(join(overlayHome, ".gemini"), { recursive: true });
writeFileSync(join(overlayHome, ".gemini", "settings.json"), buildGeminiSettings(), {
encoding: "utf8",
mode: 0o600,
});
childEnv.GEMINI_CLI_HOME = overlayHome;
}
const child = spawn(
commandSpec.command,
quoteShellArgs([...modelArgs, ...args], process.platform),
{
env: childEnv,
stdio: "inherit",
shell: commandSpec.shell,
...(process.platform === "win32" ? { windowsHide: true } : {}),
}
);
const cleanup = () => {
if (!overlayHome) return;
try {
rmSync(overlayHome, { recursive: true, force: true });
} catch {
// Best-effort cleanup; the directory contains no persistent credentials.
}
};
return await new Promise((resolve) => {
let settled = false;
const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
const finish = (code) => {
if (settled) return;
settled = true;
for (const signal of Object.keys(signalExitCode)) {
process.removeListener(signal, signalHandlers[signal]);
}
cleanup();
resolve(code);
};
const signalHandlers = {};
for (const signal of Object.keys(signalExitCode)) {
signalHandlers[signal] = () => {
try {
child.kill(signal);
} catch {
// The child may have already exited between the signal and cleanup.
}
finish(signalExitCode[signal]);
};
process.once(signal, signalHandlers[signal]);
}
child.on("error", (error) => {
if (error?.code === "ENOENT") {
console.error(`The '${target}' CLI was not found in PATH.`);
finish(127);
} else {
console.error(String(error?.message || error));
finish(1);
}
});
child.on("exit", (code, signal) => {
finish(code ?? signalExitCode[signal] ?? 0);
});
});
}
/** Build a launch plan and redact any resolved secret values. */
export async function buildRunPlan(target, rawOpts = {}, args = []) {
const canonical = resolveRunTarget(target);
if (!canonical) {
throw new Error("unsupported target");
throw new Error(
`Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}`
);
}
if (canonical === "claude") {
return buildClaudePlan(rawOpts, args);
}
return buildCodexPlan(rawOpts, args);
if (canonical === "codex") {
return buildCodexPlan(rawOpts, args);
}
return buildGenericPlan(canonical, rawOpts, args);
}
function writeDryRunOutput(plan, opts = {}) {
@@ -207,6 +485,7 @@ function writeDryRunOutput(plan, opts = {}) {
},
shell: !!plan.shell,
model: plan.model || null,
configOverlay: plan.configOverlay || null,
env: {
changedOrAdded: plan.envDiff.changedOrAdded,
removed: plan.envDiff.removed,
@@ -224,6 +503,7 @@ function writeDryRunOutput(plan, opts = {}) {
console.log(`args: ${JSON.stringify(output.args)}`);
console.log(`auth: ${JSON.stringify(output.auth)}`);
console.log(`model: ${output.model || "(not set)"}`);
if (output.configOverlay) console.log(`config overlay: ${output.configOverlay}`);
if (output.env.changedOrAdded.length) {
console.log(`env added/changed: ${output.env.changedOrAdded.join(", ")}`);
}
@@ -237,8 +517,8 @@ function buildExecutionOptionsForClaude(rawOpts) {
return {
...rawOpts,
model: resolveModelFromTargetOptions(rawOpts),
token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"],
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
token: resolveAuthTokenOption(rawOpts),
apiKey: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile || rawOpts.p,
};
}
@@ -247,7 +527,7 @@ function buildExecutionOptionsForCodex(rawOpts) {
return {
...rawOpts,
model: resolveModelFromTargetOptions(rawOpts),
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
apiKey: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile || rawOpts.p,
};
}
@@ -262,12 +542,18 @@ export async function runCliTarget(target, opts = {}, args = []) {
const canonical = resolveRunTarget(target);
if (!canonical) {
process.stderr.write(
`Unsupported target '${target}'. Supported targets: ${Object.keys(RUN_TARGETS).join(", ")}\n`
`Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}\n`
);
return 2;
}
const plan = await buildRunPlan(target, opts, args);
let plan;
try {
plan = await buildRunPlan(target, opts, args);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 2;
}
if (opts.dryRun) {
writeDryRunOutput(plan, opts);
@@ -278,7 +564,11 @@ export async function runCliTarget(target, opts = {}, args = []) {
return await runLaunchClaudeCommand(buildExecutionOptionsForClaude(opts), args);
}
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args);
if (canonical === "codex") {
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args);
}
return await runGenericTarget(canonical, opts, args);
}
export function registerRun(program) {
@@ -294,12 +584,15 @@ export function registerRun(program) {
"--remote <url>",
"Remote OmniRoute base URL (overrides --port, --base-url, and the active context)"
)
.option("--base-url <url>", "OmniRoute base URL (alias for --remote)")
.option("--context <name>", "Named local/remote context to use for URL and credentials")
.option("--provider <id>", "Provider id for shorthand model composition")
.option("--model <id>", "Model id to inject in the launched target where supported")
.option("--profile <name>", "Profile/alias argument for target launchers that support it")
.option("-p, --p <name>", "Alias for --profile")
.option("--token <token>", "Authentication token for the launched target (same as --api-key)")
.option("--api-key <key>", "Authentication token for the launched target")
.option("--api-key-env <name>", "Read the launch token from an environment variable")
.option("--dry-run", "Show planned command and env keys without executing")
.option("--json", "Return dry-run output in machine-readable format")
.allowUnknownOption(true)

View File

@@ -133,6 +133,29 @@ async function setupProvider(db, opts, prompt, nonInteractive) {
return connection;
}
/**
* Merge the `setup` subcommand options with the program-level ones.
*
* The program declares a global `--api-key` (the OmniRoute *server* key, see
* bin/cli/program.mjs) and `setup` declares its own `--api-key` (the *provider*
* key). Commander binds the value to the program-level option, so the
* subcommand's `opts.apiKey` is always `undefined` and `--add-provider` failed
* with "Provider API key is required" even when `--api-key` was passed. Falling
* back to the global value also makes `OMNIROUTE_API_KEY` work, which the error
* message already told users to use.
*
* @param {Record<string, unknown>} opts Subcommand options.
* @param {Record<string, unknown>} globalOpts Result of `cmd.optsWithGlobals()`.
* @returns {Record<string, unknown>} Options to hand to `runSetupCommand`.
*/
export function mergeSetupOptions(opts, globalOpts) {
return {
...opts,
apiKey: opts.apiKey ?? globalOpts.apiKey,
output: globalOpts.output,
};
}
export function registerSetup(program) {
program
.command("setup")
@@ -149,7 +172,7 @@ export function registerSetup(program) {
.option("--list", "List all supported CLI tools")
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const exitCode = await runSetupCommand({ ...opts, output: globalOpts.output });
const exitCode = await runSetupCommand(mergeSetupOptions(opts, globalOpts));
if (exitCode !== 0) process.exit(exitCode);
});

View File

@@ -80,7 +80,7 @@ async function _runAllProviders(opts) {
return 1;
}
const data = await res.json();
const connections = (data.providers ?? data.items ?? data).filter(
const connections = (data.connections ?? data.providers ?? data.items ?? data).filter(
(c) => c.authType === "apikey" || c.testStatus !== "unavailable"
);
if (connections.length === 0) {

View File

@@ -3,6 +3,108 @@ import { join, dirname } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
const CONFIG_VERSION = 1;
const KEYCHAIN_SERVICE = "omniroute-cli";
const KEYCHAIN_DISABLED = /^(1|true|yes|on)$/i.test(
String(process.env.OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED || "")
);
// `keytar` is optional and native. Keeping it behind a small interface lets
// headless installs use the same CLI without requiring libsecret/Keychain at
// install time, while tests can inject a deterministic fake backend.
let keychainBackend = null;
let keychainOperational = true;
let warnedPlaintextFallback = false;
const credentialCache = new Map();
function isKeychainBackend(value) {
return (
value &&
typeof value.getPassword === "function" &&
typeof value.setPassword === "function" &&
typeof value.deletePassword === "function"
);
}
async function loadKeychainBackend() {
if (KEYCHAIN_DISABLED) return null;
try {
const imported = await import("keytar");
const candidate = isKeychainBackend(imported?.default) ? imported.default : imported;
return isKeychainBackend(candidate) ? candidate : null;
} catch {
// Native keychain modules are optional and commonly unavailable in
// containers. The secure file fallback is handled explicitly below.
return null;
}
}
function parseCredential(value) {
if (!value || typeof value !== "string") return null;
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const result = {};
if (typeof parsed.accessToken === "string" && parsed.accessToken) {
result.accessToken = parsed.accessToken;
}
if (typeof parsed.apiKey === "string" && parsed.apiKey) result.apiKey = parsed.apiKey;
return result.accessToken || result.apiKey ? result : null;
} catch {
// Older/externally managed entries may contain one raw token.
return { accessToken: value };
}
}
function credentialForContext(context) {
const ref = context && typeof context.credentialRef === "string" ? context.credentialRef : "";
return ref ? credentialCache.get(ref) || null : null;
}
function applyCachedCredential(context) {
const cached = credentialForContext(context);
if (!cached) return { ...context };
return { ...context, ...cached };
}
async function hydrateCredentialCache(cfg) {
if (!keychainBackend || !keychainOperational) return;
const contexts = cfg?.contexts || cfg?.profiles || {};
for (const context of Object.values(contexts)) {
const ref = context && typeof context === "object" ? context.credentialRef : null;
if (!ref || credentialCache.has(ref)) continue;
try {
const parsed = parseCredential(await keychainBackend.getPassword(KEYCHAIN_SERVICE, ref));
if (parsed) credentialCache.set(ref, parsed);
} catch {
keychainOperational = false;
break;
}
}
}
function warnPlaintextFallback() {
if (warnedPlaintextFallback) return;
warnedPlaintextFallback = true;
process.stderr.write(
"Warning: OS keychain unavailable; context credentials use config.json mode 0600 fallback.\n"
);
}
function readConfigFile() {
try {
if (!existsSync(configPath())) return defaultConfig();
const parsed = JSON.parse(readFileSync(configPath(), "utf8"));
return parsed && typeof parsed === "object" ? parsed : defaultConfig();
} catch {
return defaultConfig();
}
}
// Resolve keychain state before importing commands can call the synchronous
// compatibility helpers below. Credentials themselves stay in memory; only a
// stable reference is persisted in config.json when keytar is available.
keychainBackend = await loadKeychainBackend();
await hydrateCredentialCache(readConfigFile());
export function configPath() {
return join(resolveDataDir(), "config.json");
@@ -19,14 +121,13 @@ function defaultConfig() {
}
export function loadContexts() {
try {
if (!existsSync(configPath())) return defaultConfig();
return JSON.parse(readFileSync(configPath(), "utf8"));
} catch {
return defaultConfig();
}
return readConfigFile();
}
/**
* Synchronous compatibility writer. New credential-bearing code should use
* `saveContextsSecure()` so tokens are moved to the OS keychain when possible.
*/
export function saveContexts(cfg) {
const path = configPath();
mkdirSync(dirname(path), { recursive: true });
@@ -36,6 +137,116 @@ export function saveContexts(cfg) {
} catch {}
}
/** Stable keychain reference; the reference itself is safe to persist in JSON. */
export function contextCredentialRef(name) {
return `${KEYCHAIN_SERVICE}:context:${encodeURIComponent(String(name))}`;
}
/** Expose a non-secret capability status for diagnostics and tests. */
export function getContextKeychainStatus() {
return {
available: Boolean(keychainBackend && keychainOperational),
disabled: KEYCHAIN_DISABLED,
fallback: !keychainBackend || !keychainOperational,
};
}
/**
* Store context credentials through keytar and write only a credentialRef to
* config.json. If keytar cannot be used, preserve the credential in the
* mode-0600 file and emit one explicit warning instead of breaking headless
* installs.
*/
export async function saveContextsSecure(cfg) {
const source = cfg && typeof cfg === "object" ? cfg : defaultConfig();
const next = JSON.parse(JSON.stringify(source));
next.version = next.version || CONFIG_VERSION;
if (!next.contexts && next.profiles) {
next.contexts = next.profiles;
delete next.profiles;
}
next.contexts = next.contexts || {};
for (const [name, raw] of Object.entries(next.contexts)) {
const context = raw && typeof raw === "object" ? raw : {};
const accessToken = typeof context.accessToken === "string" ? context.accessToken : "";
const apiKey = typeof context.apiKey === "string" ? context.apiKey : "";
const hasCredential = Boolean(accessToken || apiKey);
if (hasCredential && keychainBackend && keychainOperational) {
const ref =
typeof context.credentialRef === "string" && context.credentialRef
? context.credentialRef
: contextCredentialRef(name);
try {
await keychainBackend.setPassword(
KEYCHAIN_SERVICE,
ref,
JSON.stringify({
...(accessToken ? { accessToken } : {}),
...(apiKey ? { apiKey } : {}),
})
);
credentialCache.set(ref, {
...(accessToken ? { accessToken } : {}),
...(apiKey ? { apiKey } : {}),
});
context.credentialRef = ref;
delete context.accessToken;
delete context.apiKey;
} catch {
keychainOperational = false;
warnPlaintextFallback();
}
} else if (hasCredential) {
warnPlaintextFallback();
}
next.contexts[name] = context;
}
saveContexts(next);
return {
usedKeychain: Boolean(keychainBackend && keychainOperational),
config: next,
};
}
/** Remove the keychain entry associated with a context, if one exists. */
export async function deleteContextCredential(name, context) {
const cfg = loadContexts();
const candidate = context || cfg.contexts?.[name] || cfg.profiles?.[name] || {};
const ref = candidate.credentialRef || contextCredentialRef(name);
credentialCache.delete(ref);
if (!keychainBackend || !keychainOperational) return false;
try {
await keychainBackend.deletePassword(KEYCHAIN_SERVICE, ref);
return true;
} catch {
keychainOperational = false;
return false;
}
}
/** Explicitly migrate legacy plaintext context credentials. */
export async function migrateContextCredentials() {
const cfg = loadContexts();
const pending = Object.values(cfg.contexts || cfg.profiles || {}).some(
(context) => context?.accessToken || context?.apiKey
);
if (!pending) return { migrated: false, pending: false, ...getContextKeychainStatus() };
const result = await saveContextsSecure(cfg);
return { migrated: result.usedKeychain, pending: true, ...getContextKeychainStatus() };
}
/** Test-only backend injection; no secret is returned by this function. */
export async function setContextKeychainBackendForTests(backend) {
keychainBackend = isKeychainBackend(backend) ? backend : null;
keychainOperational = true;
credentialCache.clear();
await hydrateCredentialCache(readConfigFile());
}
/**
* Resolve the active context for a CLI invocation.
*
@@ -54,7 +265,13 @@ export function resolveActiveContext(overrideName) {
const contexts = cfg.contexts || cfg.profiles || {};
const name = overrideName || cfg.currentContext || cfg.activeProfile || "default";
const found = contexts[name] || contexts.default;
if (found) return found;
if (found) return applyCachedCredential(found);
if (cfg.baseUrl) return { baseUrl: cfg.baseUrl };
return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` };
}
/** Async variant for callers that need to observe a just-created keychain entry. */
export async function resolveActiveContextAsync(overrideName) {
await hydrateCredentialCache(readConfigFile());
return resolveActiveContext(overrideName);
}

View File

@@ -1300,7 +1300,7 @@
"description": "Manage scoped CLI access tokens (remote mode)"
},
"configure": {
"description": "Pick a provider+model from the active server and write a local CLI config"
"description": "Pick a provider+model from the active server and configure a supported local CLI"
},
"launchCodex": {
"description": "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"

View File

@@ -1297,7 +1297,7 @@
"description": "Gerencia tokens de acesso CLI com escopo (modo remoto)"
},
"configure": {
"description": "Escolhe um provedor+modelo do servidor ativo e grava uma configuração de CLI local"
"description": "Escolhe um provedor+modelo do servidor ativo e configura uma CLI local compatível"
},
"launchCodex": {
"description": "Inicia o Codex CLI apontando para o OmniRoute (local ou VPS remoto)"

View File

@@ -0,0 +1,109 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
import { join, dirname } from "node:path";
import { resolveDataDir } from "./data-dir.mjs";
const PREFERENCES_VERSION = 1;
const MAX_RECENT = 12;
const MAX_FAVORITES = 32;
export function modelPreferencesPath() {
return join(resolveDataDir(), "model-preferences.json");
}
function defaultPreferences() {
return { version: PREFERENCES_VERSION, targets: {}, contexts: {} };
}
export function loadModelPreferences() {
try {
const path = modelPreferencesPath();
if (!existsSync(path)) return defaultPreferences();
const parsed = JSON.parse(readFileSync(path, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return defaultPreferences();
}
return {
version: PREFERENCES_VERSION,
targets: parsed.targets && typeof parsed.targets === "object" ? parsed.targets : {},
contexts: parsed.contexts && typeof parsed.contexts === "object" ? parsed.contexts : {},
};
} catch {
return defaultPreferences();
}
}
function saveModelPreferences(preferences) {
const path = modelPreferencesPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(preferences, null, 2));
try {
chmodSync(path, 0o600);
} catch {
// Best effort on platforms without POSIX modes.
}
}
function normalizeIds(values) {
return [...new Set((Array.isArray(values) ? values : []).filter((id) => typeof id === "string"))];
}
function targetState(preferences, target, contextKey) {
const raw = contextKey
? preferences.contexts?.[contextKey]?.[target] ||
(contextKey === "default" ? preferences.targets?.[target] : undefined)
: preferences.targets?.[target];
return {
favorites: normalizeIds(raw?.favorites),
recent: normalizeIds(raw?.recent),
};
}
function writeTargetState(preferences, target, contextKey) {
if (!contextKey) {
preferences.targets[target] = targetState(preferences, target);
return preferences.targets[target];
}
preferences.contexts = preferences.contexts || {};
preferences.contexts[contextKey] = preferences.contexts[contextKey] || {};
preferences.contexts[contextKey][target] = targetState(preferences, target, contextKey);
return preferences.contexts[contextKey][target];
}
/** Rank catalog IDs with favorites first, then recent choices, then catalog order. */
export function rankPreferredModels(
target,
modelIds,
preferences = loadModelPreferences(),
contextKey = ""
) {
const ids = normalizeIds(modelIds);
const state = targetState(preferences, target, contextKey);
const available = new Set(ids);
const preferred = [...state.favorites, ...state.recent].filter((id) => available.has(id));
return [...new Set([...preferred, ...ids])];
}
/** Record a successful selection without storing server URLs or credentials. */
export function recordModelPreference(target, modelId, options = {}) {
if (!target || !modelId) return loadModelPreferences();
const preferences = loadModelPreferences();
const state = writeTargetState(preferences, target, options.context || "");
state.recent = [modelId, ...state.recent.filter((id) => id !== modelId)].slice(0, MAX_RECENT);
if (options.favorite) {
state.favorites = [modelId, ...state.favorites.filter((id) => id !== modelId)].slice(
0,
MAX_FAVORITES
);
}
if (options.unfavorite) state.favorites = state.favorites.filter((id) => id !== modelId);
saveModelPreferences(preferences);
return preferences;
}
export function getModelPreferenceState(
target,
preferences = loadModelPreferences(),
contextKey = ""
) {
return targetState(preferences, target, contextKey);
}

View File

@@ -1,22 +1,39 @@
import crypto from "node:crypto";
const SALT = "omniroute-cli-auth-v1";
const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1";
export const CLI_TOKEN_HEADER = "x-omniroute-cli-token";
let _cached = null;
let _cachedSalt = null;
/** Mirrors getActiveSalt() in src/lib/machineToken.ts so a rotated
* OMNIROUTE_CLI_SALT reaches the CLI too (docs/security/CLI_TOKEN.md). */
function getActiveSalt() {
return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT;
}
export async function getCliToken() {
if (_cached !== null) return _cached;
const salt = getActiveSalt();
if (_cached !== null && _cachedSalt === salt) return _cached;
try {
const { machineIdSync } = await import("node-machine-id");
const mid = machineIdSync();
_cached = crypto
.createHash("sha256")
.update(mid + SALT)
.digest("hex")
.substring(0, 32);
} catch {
// 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");
// 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");
} 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 = "";
}
_cachedSalt = salt;
return _cached;
}

View File

@@ -0,0 +1,2 @@
- **feat(admission):** add lane-aware admission probes for combo/fusion/chaos fan-out (fail-open, queueing disabled), an env-wins `OMNIROUTE_CHAT_VIRTUAL_LANES` activation flag applied at boot, and adaptive-lane visibility in the `omniroute_get_health` MCP tool (related to #9654)
- **docs(mcp):** complete the MCP server README tool reference so the `schemas/` catalog is fully covered (agent-skills, oneproxy, web, tool-search, combo/routing, pricing and DB-health tools were previously only discoverable via `omniroute_tool_search`)

View File

@@ -0,0 +1 @@
- feat(providers): add **Cloudflare AI Playground** as a No Auth provider (`cloudflare-playground`, alias `cfp`) — free anonymous chat over the reverse-engineered `cf_agent` WebSocket protocol (PartySocket transport, no account/API key/cookies) with GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro, gpt-oss-120B, Llama 3.3 70B, Qwen2.5 Coder 32B and 14 more curated models. The executor drives a headless Chromium via Playwright (the WS upgrade is TLS-fingerprint-gated), translates the `cf_agent` frame stream into OpenAI SSE, and surfaces upstream rate limits (3021) as HTTP 429. Fixes #10389

View File

@@ -0,0 +1,2 @@
- **feat(providers):** AI Horde accepts an optional registered API key and advertises only live image models that currently have workers ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542))
- **fix(providers):** AI Horde Check validates keys via `/v2/find_user` instead of the unauthenticated OpenAI models list ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542))

View File

@@ -0,0 +1 @@
- **feat(providers):** complete Jina AI as one credential pool — dashboard `jina-ai` / `jina-reader` share a token, `JINA_AI_API_KEY` is a real fallback, Test probes `GET https://api.jina.ai/v1/models` (embeddings fallback hits `jina-embeddings-v5-omni-small`), embed/rerank logs keep `connection_id`, catalog adds `jina-reranker-v3.5`, Omni v5 multimodal `{text}`/`{image}`/`{content}` docs pass through intact, and OmniRoute proxies classify / segment / `jina-search` (`s.jina.ai`). Reader stays a separate `r.jina.ai` card with an explicit label. Gemini Embedding 2 (`gemini/gemini-embedding-2`, alias `google/gemini-embedding-2`) uses dashboard `gemini` keys (or `GEMINI_API_KEY` / `GOOGLE_API_KEY` only when none exist), forwards native multimodal parts, and maps N OpenAI `input` items to N `:batchEmbedContents` vectors instead of one aggregated `:embedContent`. ([#10581](https://github.com/diegosouzapw/OmniRoute/pull/10581))

View File

@@ -0,0 +1 @@
- **feat(settings):** add `autoDisableBannedScope` so permanent-ban auto-disable can target subscription/OAuth accounts only, leaving prepaid API keys in the routing pool ([#10617](https://github.com/diegosouzapw/OmniRoute/pull/10617))

View File

@@ -0,0 +1 @@
- **feat(api):** add `GET`/`POST` `/v1/multimodal-embeddings` as an alias of `/v1/embeddings` so Jina-compatible clients do not receive HTTP 404 `unknown_route` — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- **feat(routing):** add client-, provider-, and model-neutral exclusive managed session connection leases with API-key-bound generation fencing, durable SQLite ownership, explicit allowlist policy, and bounded 429 capacity retry semantics.

View File

@@ -0,0 +1 @@
- **Passthrough streaming:** stop leaking upstream SSE control lines (`id:`/`event:`/`retry:`/`:` comments) to plain OpenAI Chat-Completions-format clients, while preserving `event:` framing for OpenAI Responses API and Claude Messages API passthrough ([#10017](https://github.com/diegosouzapw/OmniRoute/issues/10017)).

View File

@@ -0,0 +1,2 @@
- Fix: wire AgentRouter's existing console balance fetcher into the Dashboard Quota UI (visibility gate + provider-limits data path + background sync) so its wallet balance renders instead of falling back to "Usage API not implemented" (#10078)
- Fix: AgentRouter's dollar balance now renders as a currency-formatted "$X.XX" credits row in the Dashboard Quota UI instead of a bare percentage, and an exhausted wallet always shows exactly $0.00 (#10078)

View File

@@ -0,0 +1 @@
- fix(sse): bridge generic openai-compatible/anthropic-compatible provider type ids to their concrete uuid node id in credential lookup (#10085)

View File

@@ -0,0 +1 @@
- fix(dashboard): remap unified Kimi Code card API-key save to the admitted `kimi-coding-apikey` connection id, fixing 400 "Invalid provider" on Save (#10096)

View File

@@ -0,0 +1 @@
- fix(antigravity): strip trailing model turn for native Gemini requests too, not just Claude (#10104)

View File

@@ -0,0 +1 @@
- **fix(admission):** stop the adaptive latency-gradient collapse from permanently locking out ordinary requests — individually valid requests now make solo progress when the system is idle and normal pressure, and the collapsed limit actively recovers on sustained idle windows instead of being stuck; the critical-pressure fuse still wins over solo progress (#10111)

View File

@@ -0,0 +1 @@
- fix(sse): downgrade client-supplied `thinking:{type:"adaptive"}` to `enabled` and gate the `context-1m-2025-08-07` beta on model eligibility when a combo/fallback re-routes a request to a non-adaptive/non-1M model like claude-haiku-4-5 (avoids "adaptive thinking is not supported on this model" and "long context beta is not yet available" 400s, #10119)

View File

@@ -0,0 +1 @@
- **fix(logging):** move call-log artifact serialization and filesystem writes to a bounded singleton worker to keep request handling responsive (#10123)

View File

@@ -0,0 +1 @@
- fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (local-first, cloud-metadata still blocked) (#10158)

View File

@@ -0,0 +1 @@
- fix(cli): guarantee a non-empty `[STARTUP] Fatal:` log line for any instrumentation-hook boot throw, not just DB-driver init failures (#10171)

View File

@@ -0,0 +1 @@
- fix(sse): gate structural chat admission shedding on real heap pressure instead of unconditional capacity, with a bounded headroom budget so a healthy heap can no longer bypass admission control indefinitely (#10183, #10268)

View File

@@ -0,0 +1 @@
- **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225))

View File

@@ -0,0 +1 @@
- **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244)

View File

@@ -0,0 +1 @@
- fix(open-sse): stop concurrent requests colliding on the same dedup hash for non-OpenAI target formats (#10249)

View File

@@ -0,0 +1 @@
- **fix(translator):** Text-format tool calls emitted inline by some models are now converted to proper `tool_use` blocks. Certain models (DeepSeek, Qwen) return tool invocations as `<tool_call>{"name":"Bash","arguments":{…}}</tool_call>` or `TOOL_CALL Read: {"file_path":"…"}` inside the text stream instead of the structured `tool_calls` field. Both formats leaked through the Claude translators as plain text, so Claude Code rendered the raw block and stalled instead of executing the tool. `extractXmlInvokeBlocks` (previously `<invoke>`-only) now scans for all three shapes in a single pass and emits `content_block_start`/`input_json_delta`/`content_block_stop` events, in both `openai-to-claude` and `gemini-to-claude` (Antigravity) paths ([#10251](https://github.com/diegosouzapw/OmniRoute/pull/10251))

View File

@@ -0,0 +1 @@
- fix(dashboard): make provider card warning indicators expose the interaction they advertise (#10261)

View File

@@ -0,0 +1 @@
- **fix(ops):** Docker HEALTHCHECK defaults to the lightweight `/healthz` lifecycle probe instead of the heavy `/api/monitoring/health` path, with an `OMNIROUTE_HEALTHCHECK_PATH` opt-in override ([#10311](https://github.com/diegosouzapw/OmniRoute/pull/10311))

View File

@@ -0,0 +1 @@
- fix(resilience): keep combo quality and auth failure reasons separate and redact connection labels in terminal errors (#10314)

View File

@@ -0,0 +1 @@
- fix(dashboard): send periodic WS heartbeat pings so live dashboard connections stop dropping every ~35s (#10319)

View File

@@ -0,0 +1 @@
- fix(providers): GitLab Duo falls back to the public Code Suggestions endpoint when direct_access returns 401 (#10365)

View File

@@ -0,0 +1 @@
- **fix(translator):** Consolidate tool-name casing normalization into a single `restoreClaudeToolName` helper reused across every response path (`openai-to-claude`, `gemini-to-claude`, `stream` passthrough, xAI and Antigravity handlers), replacing six hand-copied 7-entry casing maps. The shared helper resolves via the request-side `toolNameMap` first (preserving declared PascalCase and MCP/alias names), then the complete `TOOL_RENAME_MAP` (which already covers `glob`/`grep`/`task`/`todowrite`/`skill`/`askuserquestion`/etc.), then the `#7926` TitleCase→lowercase fallback for map-less clients. This closes the coverage gap that left `TodoWrite` and other tools failing with `Error: No such tool available: todowrite`, fixes a `ReferenceError` in `remapToolNamesInResponse`, and preserves the Gemini thought-signature persistence (`#8979`) and OpenAI→Claude `toolNameMap` restoration that must not regress ([#10374](https://github.com/diegosouzapw/OmniRoute/issues/10374))

View File

@@ -0,0 +1 @@
- **fix(responses):** preserve native tool definitions for custom OpenAI-compatible providers when using the Responses API (`/v1/responses`). When `apiType` is set to `"responses"` (or `_omnirouteForceResponsesUpstream` is enabled), OmniRoute passes native tool shapes (`custom` with lark grammars, `namespace`, `local_shell`) directly upstream without running a lossy Responses→Chat→Responses conversion ([#10374](https://github.com/diegosouzapw/OmniRoute/issues/10374))

View File

@@ -0,0 +1 @@
- fix(dashboard): Free Tier 'used this month' now includes live usage_history rows, not just the rolled-up daily summary (#10381)

View File

@@ -0,0 +1,2 @@
- **fix(memory):** auto-check Qdrant health on mount and stop the false-red status badge on `/dashboard/memory?tab=engine` — the badge treated "not yet checked" (`health === null`) as a failure, so a healthy Qdrant showed red after every page refresh until "Test connection" was clicked; settings changes now also invalidate the stale result and re-check after the save persists, so a health check racing the settings PUT can no longer keep the badge red until a manual re-test ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489))
- **test(compression):** align source-contract tests with the merged `release/v3.8.50` base (`aa912c42a`) — accept the multi-line `providerTransport` shape in `omniglyph-chatcore-plumbing` and give the pipeline-circuit-breaker fixture a `metadata.executionStages` (both structural changes landed in the base merge) ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489))

View File

@@ -0,0 +1 @@
- **fix(providers):** zed-hosted OAuth now redirects the browser back to the dashboard's own loopback port (auto-completing the login), and the manual paste path accepts Zed's user_id/access_token callback URL instead of erroring with "No authorization code found" ([#10517](https://github.com/diegosouzapw/OmniRoute/pull/10517)) - thanks @phatchau036

View File

@@ -0,0 +1 @@
- **fix(providers):** test token-backed web sessions through their provider validator instead of the OAuth path ([#10519](https://github.com/diegosouzapw/OmniRoute/pull/10519)) — thanks @Zartharas

View File

@@ -0,0 +1 @@
- **fix(compliance):** redact additional provider API keys from audit-log payloads ([#10521](https://github.com/diegosouzapw/OmniRoute/pull/10521)) — thanks @Zartharas

View File

@@ -0,0 +1 @@
- **fix(models):** align Codex GPT-5.6 context limits with the Codex catalog and honor model context overrides when advertising combos ([#10530](https://github.com/diegosouzapw/OmniRoute/issues/10530))

View File

@@ -0,0 +1 @@
- **fix(deepseek):** Advertise `none`, `low`, `high`, and `max` for V4 Pro and Flash, derive OpenCode Go effort aliases from base-model metadata, and route those models through native Responses ([#10540](https://github.com/diegosouzapw/OmniRoute/pull/10540)) — thanks @jackjinke

View File

@@ -0,0 +1 @@
- **fix(a2a):** use a constant-time bearer compare in `/api/a2a/tasks` via `crypto.timingSafeEqual`, matching the `tokensMatch` helper already used in `src/app/a2a/route.ts` and removing the last non-constant secret comparison in the repo ([#10544](https://github.com/diegosouzapw/OmniRoute/pull/10544))

View File

@@ -0,0 +1 @@
- **fix(providers):** OpenCode `x-opencode-session` now derives a stable, conversation-scoped fingerprint via `generateSessionId()` instead of a fresh random UUID per request, so upstream prompt caching can hit across requests in the same conversation; bare `big-pickle`/`*-free` model ids now keep routing to an active opencode-family connection even when its synced catalog is temporarily stale; and bare requests to no-auth catalog providers (e.g. `opencode`) now echo the listing-valid `<alias>/<model>` form in `response.model` so clients validating against `/v1/models` don't warn ([#10571](https://github.com/diegosouzapw/OmniRoute/pull/10571))

View File

@@ -0,0 +1 @@
- **fix(mcp):** make GitHub skill tools discoverable through `omniroute_tool_search`

View File

@@ -0,0 +1 @@
- **fix(audio):** when a prefix-matched STT provider has no credentials, retry gateways that list the same nested model id (e.g. `deepgram/nova-3``openrouter/deepgram/nova-3`) and mention those ids in the 400; stop documenting bare `deepgram/nova-3` as the default example ([#10583](https://github.com/diegosouzapw/OmniRoute/issues/10583))

View File

@@ -0,0 +1 @@
- **fix(xai):** trim Chat Completions `messages` and Responses `input` to xAI's 800-item history cap before dispatch, so long tool loops no longer die on `413 Chat history exceeds the 800-message limit` ([#10601](https://github.com/diegosouzapw/OmniRoute/pull/10601))

View File

@@ -0,0 +1 @@
- **fix(cli):** derive the machine-id token correctly under plain Node — `await import("node-machine-id")` puts the CJS exports on `.default`, so the destructured `machineIdSync` was `undefined` and the catch blanked the token, sending every management request unauthenticated; `OMNIROUTE_CLI_SALT` rotation is now honored too ([#10612](https://github.com/diegosouzapw/OmniRoute/pull/10612))

View File

@@ -0,0 +1 @@
- **fix(cli):** `omniroute setup --add-provider --api-key <key>` no longer aborts with "Provider API key is required" — Commander bound the value to the program-level `--api-key` (the OmniRoute server key), leaving the subcommand's own option undefined; `OMNIROUTE_API_KEY` now works as the error message advertised ([#10613](https://github.com/diegosouzapw/OmniRoute/pull/10613))

View File

@@ -0,0 +1 @@
- **fix(auto):** rate-limit `auto/<family> matched no connected models` warnings to once per minute per label (`open-sse/services/autoCombo/virtualFactory.ts`)

View File

@@ -0,0 +1 @@
- **fix(providers):** register live OpenRouter Gemini Embedding 2 ids (`google/gemini-embedding-2` and `google/gemini-embedding-2-preview`, 3072-d) in the curated embeddings catalog so `GET /v1/models` and `GET /v1/embeddings` list the ids that already serve — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- **fix(api):** `/v1/embeddings` 400s for native `gemini-embedding-2` now name the working OpenRouter ids (`openrouter/google/gemini-embedding-2` and the preview alias) instead of only `No credentials for embedding provider: gemini` — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- **docs:** add an embeddings client runbook with live-verified working/broken model ids and Hindsight 0.9.1 / Memorix 1.6.0 notes — thanks @RaviTharuma

View File

@@ -1491,11 +1491,6 @@
"count": 1
}
},
"tests/integration/mimocode-proxy.integration.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 13
}
},
"tests/integration/obsidian-plugin-e2e.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 12
@@ -2559,11 +2554,6 @@
"count": 2
}
},
"tests/unit/mimocode-executor.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 58
}
},
"tests/unit/minimax-tts-1043.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6

View File

@@ -1,4 +1,5 @@
{
"_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).",
"_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).",
"_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)",
"_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.",
@@ -378,7 +379,6 @@
"open-sse/mcp-server/tools/advancedTools.ts": 1456,
"open-sse/services/accountFallback.ts": 2571,
"open-sse/services/adobeFireflyBrowserLogin.ts": 1771,
"open-sse/services/adobeFireflyChromeRuntime.ts": 1561,
"open-sse/services/adobeFireflyClient.ts": 3899,
"open-sse/services/adobeFireflySession.ts": 1304,
"open-sse/services/claudeCodeCompatible.ts": 1563,
@@ -446,7 +446,8 @@
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387,
"_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
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1019
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
@@ -582,7 +583,7 @@
"src/lib/memory/retrieval.ts": "1073",
"src/lib/tailscaleTunnel.ts": "1202",
"src/lib/usage/providerLimits.ts": "1013",
"src/shared/components/OAuthModal.tsx": "1134",
"src/shared/components/OAuthModal.tsx": "1146",
"src/shared/components/RequestLoggerV2.tsx": "1629",
"src/shared/components/analytics/charts.tsx": "1035",
"src/shared/services/cliRuntime.ts": "1122",
@@ -610,5 +611,6 @@
"_rebaseline_2026_08_12_v3850_basereds_round3": "Base-reds round 3 (#9985, 2026-08-12): ModelSelectModal.tsx 1135->1138 = base drift from the #10198 SWR/build repair (flagged as non-blocking drift by Release-Green run 31634993212, rebaselined here so the PR queue's Fast Quality Gates stop failing on inherited drift); gateways.ts 1215->1250 = base drift from the 08-12 merges (#10131 regolo/naga-ac repair, #9210 void-ai+helixmind) plus this PR restoring the chatanywhere metadata entry that round 2 dropped along with its duplicate (wave3 audited entry, +16 lines; same god-file no-split rationale as the 2026-08-11 annotation). Owner-authorized sweep (/sweep-reds).",
"_rebaseline_2026_08_12_proxyfetch_redaction": "Base-reds round 3 (#9985): proxyFetch.ts 1220->1239 (+19) = redactProxyDetailsInMessage() helper closing the credential leak #10032 reintroduced (raw proxy URL with user:password appended to the propagated error, Hard Rule #12); irreducible security fix at the existing error-surface chokepoint. Covered by tests/unit/tls-proxy-context.test.ts (strengthened leak guards).",
"_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_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)."
}

View File

@@ -1,176 +1,33 @@
{
"open-sse/executors/azure-openai.ts": {
"TS2345": 1
},
"open-sse/executors/chatgpt-web.ts": {
"TS2339": 1
},
"open-sse/executors/claude-web/stream.ts": {
"TS2322": 1,
"TS2345": 1
},
"open-sse/executors/copilot-web.ts": {
"TS2353": 1
},
"open-sse/executors/deepseek-web.ts": {
"TS2352": 1
},
"open-sse/executors/default.ts": {
"TS2352": 1
},
"open-sse/executors/duckduckgo-web.ts": {
"TS2345": 2
},
"open-sse/executors/duckduckgo-web/challenge.ts": {
"TS2304": 1
},
"open-sse/executors/edgeTts.ts": {
"TS2345": 1
},
"open-sse/executors/gemini-business.ts": {
"TS2339": 1
},
"open-sse/executors/ghe-copilot.ts": {
"TS2554": 1
},
"open-sse/executors/inner-ai.ts": {
"TS2352": 2
},
"open-sse/executors/theoldllm.ts": {
"TS2322": 1
},
"open-sse/executors/veoaifree-web.ts": {
"TS2322": 1
},
"open-sse/executors/windsurf.ts": {
"TS2322": 1
},
"open-sse/handlers/chatCore.ts": {
"TS2339": 30,
"TS2322": 1,
"TS2345": 11
},
"open-sse/handlers/chatCore/claudeUpstreamMessages.ts": {
"TS2345": 1
},
"open-sse/handlers/chatCore/clientUsageBuffer.ts": {
"TS2345": 1
},
"open-sse/handlers/chatCore/clineResponseEnvelope.ts": {
"TS2698": 1
},
"open-sse/handlers/chatCore/compressionAnalyticsWrite.ts": {
"TS2724": 1
},
"open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts": {
"TS2322": 2
},
"open-sse/handlers/chatCore/sanitization.ts": {
"TS2339": 1,
"TS2537": 1
},
"open-sse/handlers/chatCore/semanticCacheStore.ts": {
"TS2345": 1
},
"open-sse/handlers/chatCore/streamingPipeline.ts": {
"TS2345": 2
},
"open-sse/handlers/chatCore/streamingSemanticCacheStore.ts": {
"TS2345": 1
},
"open-sse/handlers/chatCore/thinkingSignatureRecovery.ts": {
"TS2339": 2
},
"open-sse/handlers/imageGeneration.ts": {
"TS2554": 2
},
"open-sse/handlers/responsesHandler.ts": {
"TS2339": 1,
"TS2345": 1
},
"open-sse/handlers/sseParser.ts": {
"TS2322": 2
},
"open-sse/handlers/videoGeneration.ts": {
"TS2339": 2
},
"open-sse/mcp-server/tools/compressionTools.ts": {
"TS2339": 2
},
"open-sse/services/__tests__/specificityDetector.test.ts": {
"TS2353": 2
},
"open-sse/services/browserBackedChat.ts": {
"TS2322": 1,
"TS2794": 1
"TS2353": 2
},
"open-sse/services/claudeAdaptiveThinking.ts": {
"TS2352": 2
},
"open-sse/services/comboManifestMetrics.ts": {
"open-sse/services/compression/engines/omniglyphAdapter.ts": {
"TS2307": 1
},
"open-sse/services/compression/engines/ccr/index.ts": {
"open-sse/services/compression/stats.ts": {
"TS2307": 1
},
"open-sse/utils/cursorImages.ts": {
"TS2339": 1
},
"open-sse/services/payloadRules.ts": {
"TS2677": 1
},
"open-sse/services/tokenLimitCounter.ts": {
"TS2551": 1
},
"open-sse/transformer/responsesTransformer.ts": {
"open-sse/utils/imageNormalize.ts": {
"TS2339": 1
},
"open-sse/utils/stream.ts": {
"TS2339": 7,
"TS2345": 1,
"TS2556": 1
"TS2345": 2,
"TS2322": 2
},
"src/app/api/v1/_shared/mediaGenerationRoute.ts": {
"TS2339": 2
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/markdown.ts": {
"TS2307": 2
},
"src/app/api/v1/models/catalog.ts": {
"TS2345": 1
},
"src/app/api/v1/models/catalogVision.ts": {
"TS2322": 1
},
"src/app/api/v1/videos/generations/route.ts": {
"src/lib/guardrails/videoBridgeHelpers.ts": {
"TS2488": 1,
"TS2365": 2,
"TS2322": 1,
"TS2345": 1
},
"src/lib/guardrails/visionBridge.ts": {
"TS2345": 1
},
"src/lib/providers/codexFastTier.ts": {
"TS2367": 1
},
"src/lib/skills/builtins.ts": {
"TS2322": 1
},
"src/lib/skills/injection.ts": {
"TS2339": 1
},
"src/lib/skills/webFetchExecution.ts": {
"TS2322": 1
},
"src/lib/streamingPiiTransform.ts": {
"TS2345": 1
},
"src/shared/providers/webSessionCredentials.ts": {
"TS2353": 1,
"TS2322": 1
},
"src/shared/validation/helpers.ts": {
"TS2339": 1
},
"src/sse/handlers/chat.ts": {
"TS2352": 1,
"TS2322": 2,
"TS2339": 1
},
"src/sse/services/model.ts": {
"TS2339": 4
}
}

View File

@@ -179,7 +179,7 @@
"_rebaseline_2026_07_28_ci_runner_delta": "189 -> 190 (+1). Medido 189 no devbox e 190 no runner do GitHub no MESMO commit (run 30396592013, job Quality Gates (Extended)) — mesma classe já registrada em _rebaseline_2026_07_20_aliasresolver_hook_split_7808: a versão do zizmor no runner enxerga uma finding a mais que a local, sempre da classe unpinned-uses @vN. O valor do runner é o que o gate compara, então a baseline segue o runner."
},
"vulnCount": {
"value": 10,
"value": 22,
"direction": "down",
"dedicatedGate": true
},
@@ -396,5 +396,6 @@
"_zizmor_rebaseline_2026_06_19_a11y_148_reconcile": "RECONCILIACAO CROSS-PR (release-volatil) ao mergear #4321 (a11y) APOS #4322 (R1): zizmorFindings 145 -> 148. O #4322 ja rebaselinou 139->145 (drift base 142 + 3 unpinned-uses do mutation-redundancy.yml). Este PR adiciona +3 unpinned-uses @vN do novo job 'a11y' (nightly-resilience.yml): actions/checkout@v7, actions/setup-node@v6, actions/cache@v5.0.5 — MESMA convencao @vN deliberada e INTOCADA de todos os workflows (ver _scanner_harden_workflows_2026_06_16). Total = 142 base + 3 r1 + 3 a11y = 148, MEDIDO com `node scripts/check/check-workflows.mjs --ratchet` na arvore release(com #4322)+#4321 = 148 exato. Nenhum template-injection/artipacked/cache-poisoning novo.",
"_zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse": "zizmorFindings 148 -> 152. Drift legitimo deste PR ao reutilizar o artefato next-build do job Build em package-artifact/electron-package-smoke e ao separar o build de compatibilidade Node 26: +4 unpinned-uses novos (2x actions/download-artifact@v8, actions/checkout@v7, actions/setup-node@v6). Mantida a convencao deliberada @vN dos workflows (sem SHA-pinning/manual update burden), conforme precedentes _scanner_harden_workflows_2026_06_16 e _zizmor_rebaseline_2026_06_19_*. Sem novos findings de template-injection/artipacked/cache-poisoning; medido localmente com zizmor 1.25.2 via `npm run check:workflows -- --ratchet` = 152.",
"_cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct": "cognitiveComplexity 971->1223 (+252, +26.0% over pristine 971). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). v1 was +48 on 2026-07-27; v2 = v1 +20% buffer = +58 → +252 total (cycle 971 measured pristine → 1223 ceiling). Justification: same as complexity v2 — the v3.8.50 release cut coincides with high-merge activity; owner accepted enlarging the headroom to cover the entire PREPARE phase (5 minor cycles .50-.54) without per-PR rebaseline noise, given that re-tightening is mechanical at v3.8.51 via the combo.ts/chatCore.ts decomposition work scheduled in .51/.52 (ROADMAP.md). RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (shrink of 214 from structural extraction during the decomposition campaigns, or via npm run quality:ratchet -- --update if natural shrink appears earlier). The 1009 floor still gives 38 units of post-tighten headroom vs the current pristine 971. Tracked via same roadmap issue as complexity v2. Window: v3.8.50 (release cut) → v3.8.54 close (RE-TIGHTEN at v3.8.51 prep merge per ROADMAP.md). Last entry unless measured regression. v1 entry retained below for audit trail.",
"_cognitive_rebaseline_2026_07_27_3850_relax": "cognitiveComplexity 971->1019 (+48). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). +48 covers Train 1D (+15) + headroom for 3.8.50/.51 batches. RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (from combo.ts/chatCore.ts decomposition scheduled in .51/.52 per ROADMAP.md phases). Tracked via same roadmap issue as complexity. SUPERSEDED by _cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct (v1 +20% buffer) — retained for audit. Last entry unless measured regression."
"_cognitive_rebaseline_2026_07_27_3850_relax": "cognitiveComplexity 971->1019 (+48). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). +48 covers Train 1D (+15) + headroom for 3.8.50/.51 batches. RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (from combo.ts/chatCore.ts decomposition scheduled in .51/.52 per ROADMAP.md phases). Tracked via same roadmap issue as complexity. SUPERSEDED by _cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct (v1 +20% buffer) — retained for audit. Last entry unless measured regression.",
"_vuln_rebaseline_2026_08_04_9439_cve_drift": "vulnCount 10->22 (HIGH=10, MODERATE=12, measured by osv-scanner v2.3.8 in PR #9439's own CI run). This is CVE variance, not a dependency change made by this PR: `git diff upstream/release/v3.8.50 HEAD -- package.json package-lock.json` is empty — neither file was touched anywhere in this branch's history. The osv-scanner vulnerability ratchet apparently does not run on every commit landed directly to release/v3.8.50 (same 'fast-gate PR->release skips this check' pattern already documented for check:file-size, e.g. _rebaseline_2026_07_01_v3843_release_5609), so newly-disclosed CVEs in already-present transitive dependencies accumulated on the release branch and only surfaced here because this PR's rebase onto the current release/v3.8.50 tip pulled them in. This exact scenario — 'a newly-disclosed CVE in an already-present dep can trip the gate with no dependency change on your part' — is the documented expected behavior in _osv_flip_blocking_2026_06_16_v3827 above, whose prescribed remedy is 'bump the dep, or re-baseline vulnCount with justification+issue' (docs/security/SUPPLY_CHAIN.md -> 'Variância de CVE'). osv-scanner is not available in this sandbox to enumerate the exact GHSA/CVE ids and safely bump only the affected transitive deps without a broader, separately-scoped dependency-audit pass; re-baselining here unblocks this PR without masking anything introduced by it. Tracked for follow-up: a dedicated dependency-bump PR should re-tighten vulnCount back down once the specific advisories are enumerated locally with osv-scanner installed."
}

View File

@@ -0,0 +1,9 @@
# OmniRoute Allocation Handoff
Allocation is not provider quota.
Quota pools define which API keys may consume a provider pool and how hard, soft, or burst policies apply. Provider quota is external capacity reported by a provider or an explicitly configured source. Ghostlight internal budgets are governance limits defined by the administrator.
The `ensurePool` operation is idempotent: an identical pool is unchanged, a changed allocation is updated, and a missing pool is created. This is intended for automation and bounded API callers.
The read-only status endpoint is `GET /api/omniroute/status`. The verification command is `npm run omniroute:verify`; it makes no live model request.

View File

@@ -0,0 +1,9 @@
# OmniRoute Provider Failover
Failures are classified before retry decisions are made.
Transient failures such as timeouts, network errors, rate limits, and provider 5xx responses may fail over. Authentication errors, permission errors, invalid requests, unavailable models, and unknown failures are not retried blindly.
The default cross-provider policy allows up to three provider attempts, retries rate limits and timeouts, and keeps administrative disablement separate from temporary circuit state.
Circuit states are `closed`, `open`, and `half_open`. A cooldown schedules a bounded probe; a successful probe closes the circuit and a failed probe reopens it.

View File

@@ -0,0 +1,17 @@
# OmniRoute Quota Telemetry
OmniRoute separates provider quota telemetry from Ghostlight accounting.
## Truthful states
- `healthy` means a source reported usable remaining capacity.
- `approaching_limit` means a source reported remaining capacity at or below the configured threshold.
- `exhausted` is emitted only when a source reports zero capacity or usage at its limit.
- `unavailable` means a supported source failed to return data.
- `unknown` means no supported source exists or no provider limit is known.
Unknown is not exhausted and does not disable a provider.
Sources are preferred in this order: official provider API, authenticated usage API, explicitly mapped response headers, administrator configuration, local estimates, unknown. Local estimates are never presented as provider billing data.
Response headers are parsed only through an explicit provider mapping. Generic header names are not assumed globally.

View File

@@ -0,0 +1,11 @@
# OmniRoute Routing Policy
Routing preserves the existing capability and combo selection logic, then applies allocation, health, circuit, quota, latency, reliability, model preference, and cost preference factors.
The adaptive score is explainable and returns both the selected candidate and all ranked candidates. Exhausted quota, denied allocation, and open circuits are ineligible. Unknown quota remains eligible with a neutral quota factor.
Route preview is deterministic and performs zero upstream model requests:
`POST /api/omniroute/route/preview`
The response includes candidate scores, factors, reasons, the selected provider, and `liveRequestExecuted: false`.

View File

@@ -107,6 +107,40 @@ Before #7274, `resolveSessionAffinityTtlMs()` hard-bailed to `0` for every provi
The three session-affinity headers are never forwarded upstream — executors build their own upstream headers from scratch rather than passing client headers through, so this stays an internal correlation id only.
### Exclusive managed session connection leases
**Scope:** one active managed HTTP client/session owns one eligible OmniRoute connection.
**Purpose:** provide durable exclusive connection ownership for clients that need a hard routing
fence across requests. This differs from session affinity, which is a soft continuity preference:
an exclusive lease persists lifecycle state in SQLite, enforces global active-owner and
active-connection uniqueness, and rejects a stale generation before provider dispatch.
The feature is opt-in per API key. A managed key must have the `lease:exclusive` scope and an
explicit non-empty `allowedConnections` list. Any HTTP client can use the lifecycle endpoint; no
client name, user-agent, provider, OAuth method, or model is required. The lease owns a connection,
not a model, so a model change retains the binding while the connection remains ordinarily
eligible. Normal model, quota, health, cooldown, and allowlist rules remain authoritative and may
transition the same generation to another free eligible connection.
The lifecycle is `POST /api/v1/session-leases` with JSON actions `acquire`, `renew`, and `release`.
Managed inference requests present the opaque `X-OmniRoute-Lease-Owner` value and exact
`X-OmniRoute-Lease-Generation`. The owner uses `vlo_` followed by 43 base64url characters; only
its SHA-256 hash is stored. Every final dispatch fence also binds the authenticated API key ID and
active connection ID. Lease control headers are removed from logs, retained request snapshots, and
upstream executor headers.
If ordinary routing has eligible managed candidates but every free candidate is occupied by a
foreign active lease, OmniRoute returns HTTP `429`, lease-capacity-unavailable code, a
waiting-for-capacity state, and a bounded `Retry-After` derived from the earliest relevant expiry.
Ordinary empty eligibility is not lease contention and keeps its existing routing error semantics.
Related mechanisms remain separate:
- OAuth session occupancy is process-local soft distribution for OAuth accounts.
- Account semaphores grant request-concurrency permits and end when a request completes.
- Exclusive managed session leases are durable lifecycle ownership with a generation fence.
---
## 3. Model Lockout

View File

@@ -1,7 +1,7 @@
---
title: "Admission lanes — two lane systems, what gates each, where each reports"
status: active
lastUpdated: 2026-08-09
lastUpdated: 2026-08-10
---
# Admission lanes (#9654) — two lane systems, what gates each, where each reports
@@ -34,14 +34,57 @@ complementary; operators should know which one they are looking at.
- **Tuning:** `OMNIROUTE_CHAT_VIRTUAL_LANES` + adaptive config (`maxQueueCount`,
`maxQueueCost`, `defaultMaxWaitMs`, …).
- **Reports:** `GET /api/monitoring/health``adaptiveAdmission``laneCount`,
`laneQueuedCount`, `laneQueuedCost`, `laneTenants` (opaque lane IDs, never raw keys).
`laneQueuedCount`, `laneQueuedCost`, `laneTenants` (opaque lane IDs, never raw
keys), and `virtualLanes` — the authoritative "lanes are on" flag in the snapshot.
## 3. Fan-out probes — per-target admission for combo/fusion (#9654 Wave 2)
Combo (priority / round-robin) and fusion fan out N model targets under one parent
request. Since #9654 Wave 2, **each fan-out target is gated before dispatch** by a
per-target probe (`PerTargetAdmissionHook`, built by `createPerTargetAdmissionHook`)
against the **parent's** tenant lane.
- **Scope:** every fan-out target dispatched by combo, fusion, and the chaos engine.
System 1 (byte-level) is unaffected — it never probes fan-out targets.
- **Gate:** **opt-in with system 2.** A no-op when `OMNIROUTE_CHAT_VIRTUAL_LANES`
is unset — the parent request already holds the shared-queue lease in that mode,
so probing would double-count and reject combo targets.
- **Semantics:**
- **Strictly non-blocking — skip, never queue.** `maxWaitMs 0`: a full lane
skips the target and the combo's fallback machinery (or fusion's survivor
panel) serves instead. This is deliberate: a fan-out target is redundant
work, and queueing it piles more load onto the exact congestion lanes exist
to stop. `defaultMaxWaitMs` therefore applies to the **parent request only**;
fan-out probes never wait, and there is intentionally **no knob** to make
them wait (issue history shows wait knobs produced the mass-502/504 class
#9654 prevents — revisit only if an operator reports skipped fan-out targets
hurting response quality).
- **Release-on-admit.** An admitted probe releases its lease immediately: it is
a capacity gate, not a hold. The parent's lease covers the fan-out; holding N
more would inflate shared active cost and reject other tenants. Best-effort,
not a reservation: the lane can refill between probe and dispatch, so under
heavy contention the gate may admit into a lane that is full again by the
time the target dispatches.
- **Priced from the real fan-out body.** The probe estimates cost from the
target's actual body — including the request class derived from its `stream`
flag, exactly like the parent path — so fusion panel members (`stream: false`)
are priced at the non-streaming class they will truly occupy, and priority/RR
targets at whatever the user requested.
- **Reports:** a probe skip after the first target bumps combo's per-request
`fallbackCount` (mirroring the existing fallback semantics; visible in combo
logs); fusion returns 503 when every panel member is skipped. There is
**no aggregate counter** (e.g. `virtualFanoutSkipped`) on the snapshot today —
if an operator reports they cannot tell how often the lane gate skips fan-out
targets, that is the trigger to add one.
## Which one is showing in a dashboard
- `adaptiveAdmission.laneCount` / `laneTenants`**adaptive virtual lanes** (system 2).
- A health payload with **no** `adaptiveAdmission.lane*` fields usually means
`OMNIROUTE_CHAT_VIRTUAL_LANES` is unset — the byte-level lanes (system 1) are still
active, but nothing under `adaptiveAdmission` will report lane data until it is enabled.
- `adaptiveAdmission.virtualLanes === true` → the fan-out probes of section 3 are
also active. A payload with `virtualLanes` missing or `false` means
`OMNIROUTE_CHAT_VIRTUAL_LANES` is unset — the byte-level lanes (system 1) are
still active, but nothing under `adaptiveAdmission` (and no fan-out gating) is
in effect until it is enabled.
## Why both exist

View File

@@ -182,6 +182,22 @@ With Stacked: 10K-2.5K tokens sent (78-95% eligible RTK+Caveman range
---
## Output Styles
Output styles inject a system prompt instruction to steer the model's writing style. They are defined in the output style catalog and support multiple languages and intensity levels (`lite`, `full`, `ultra`).
| Style | Description | Supported Languages | Levels |
| --- | --- | --- | --- |
| `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. | `en`, `pt-BR`, `ja`, `id`, `vi` | `lite`, `full`, `ultra` |
| `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` |
| `ponytail` | Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` |
| `i-have-adhd` | Action-first output: next action leads, steps numbered, one concrete next step, no preamble. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` |
| `terse-cjk` | Classical-Chinese ultra-terse style (locale-gated to zh). | `zh` | `lite`, `full`, `ultra` |
Each level appends a shared boundary clause ensuring that code blocks, URLs, file paths, commands, and identifiers remain verbatim.
---
## Configuration
### Dashboard
@@ -446,6 +462,60 @@ Caveman output mode is **opt-in** — set it via the combo config:
}
```
### Output Styles (catalog)
Caveman output mode above is the **legacy single-style path**. Phase 4 generalized it
into a catalog of composable output styles: `OUTPUT_STYLE_CATALOG` in
`open-sse/services/compression/outputStyles/catalog.ts`. Each style is a system-prompt
instruction that makes the model itself produce cheaper output; styles can be enabled
together and are injected in catalog order.
| Style | `id` | What it does | Instruction languages |
| --- | --- | --- | --- |
| Terse prose | `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. Same text as the legacy caveman output mode (referenced, not re-typed). | en, pt-BR, ja, id |
| Less code | `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | en only (backlog: [#10426](https://github.com/diegosouzapw/OmniRoute/issues/10426)) |
| Ponytail (lazy senior dev) | `ponytail` | "The best code is the code never written": reuse > rewrite, root cause > symptom, shortest working diff. | en, pt-BR, vi, ja, id |
| I have ADHD (action-first) | `i-have-adhd` | Action first (command/path/snippet before prose), numbered bounded steps, ONE concrete next step, no preamble/recap/closers. Adapted from [ayghri/i-have-adhd](https://github.com/ayghri/i-have-adhd) (MIT). | en, pt-BR, vi, ja, id |
| Terse CJK (文言) | `terse-cjk` | Classical-Chinese ultra-terse style. | zh (locale-gated: only offered when the detected language is `zh`) |
Every style ships three intensity levels — `lite`, `full`, `ultra` — and every level
ends with the shared boundaries clause, which keeps code blocks, file paths, commands,
error strings, URLs and identifiers verbatim.
#### How injection works
`applyOutputStyles()` (`open-sse/services/compression/outputStyles/apply.ts`) resolves
the selection against the catalog (unknown ids and locale-mismatched styles are
dropped, never an error), concatenates the selected instructions in catalog order,
appends the boundaries clause **once**, and front-loads the result into the system
prompt behind a single idempotency marker (`[OmniRoute Output Styles]`) — re-applying
is a no-op. When the detected request language has a translation, the localized
instruction is injected instead of English.
#### How to enable
In the dashboard: **Context → Settings → Compression** — one row per style with an
on/off toggle and a level selector. Programmatically, the compression config persists
the selection as:
```json
{
"outputStyles": [
{ "id": "i-have-adhd", "level": "full" },
{ "id": "less-code", "level": "lite" }
]
}
```
Back-compat: the legacy `outputMode: "caveman"` combo setting still works and maps to
`terse-prose`, byte-identical to the old injection in all four legacy languages.
The style × language matrix is pinned by
`tests/unit/compression/output-styles-i18n-matrix.test.ts`: a new style cannot ship
without at least a pt-BR translation (or an explicit tracked exception), and an
existing style cannot silently lose a locale. To add a style, see
[EXTENDING_COMPRESSION.md](./EXTENDING_COMPRESSION.md#adding-an-output-style).
### Tool Result Compression
The `toolResultCompressor.ts` module provides **5 specialized compression strategies**

View File

@@ -568,6 +568,40 @@ gate (`check:compression-budget`).
---
## Adding an Output Style
Output styles (see the [guide's catalog table](./COMPRESSION_GUIDE.md#output-styles-catalog))
are the response-side counterpart of the input engines: instead of compressing what you
send, they instruct the model to produce cheaper output. The registry is
`OUTPUT_STYLE_CATALOG` in `open-sse/services/compression/outputStyles/catalog.ts`, and
**one catalog entry is the entire feature**: the injector, the dashboard settings panel,
persistence and telemetry all enumerate the catalog — there is no other list to update.
1. **Add one entry to `OUTPUT_STYLE_CATALOG`** with `id`, `label`, `description` and the
three English `levels` (`lite`, `full`, `ultra`). Every level must end with
`${SHARED_BOUNDARIES}` so code, paths, commands, errors and URLs stay verbatim.
The instruction text must be **static and deterministic** per
`(id, level, language)` — `${SHARED_BOUNDARIES}` is the only interpolation allowed.
2. **Translate it.** Ship at least a `pt-BR` block under `i18n`; `ponytail` and
`i-have-adhd` (en, pt-BR, vi, ja, id) are the reference shape. A deliberately
single-language style sets `locale` instead (like `terse-cjk` → `zh`) and is then
only offered under that locale.
3. **Update the matrix guard** — add the style's languages to `BASELINE_LANGUAGES` in
`tests/unit/compression/output-styles-i18n-matrix.test.ts`. The gate fails any new
non-locale-gated style without the required translations unless it carries an
explicit `KNOWN_ENGLISH_ONLY` entry with a tracking issue.
4. **Add a per-style test** modeled on
`tests/unit/compression/i-have-adhd-catalog.test.ts`: catalog shape, boundaries
clause per level, and an anchor asserting each translation is written in its own
language rather than copied English.
5. **Attribution**: if the style is adapted from an upstream project, credit it in a
source comment on the entry (e.g. `i-have-adhd` → ayghri/i-have-adhd, MIT) — same
rule as "Proposing an upstream-inspired improvement" above.
No UI, schema or telemetry change is needed — those surfaces render from the catalog.
---
## Best Practices
### Engine Development

View File

@@ -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 (341 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 (340 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

View File

@@ -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: 341 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: 340 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

View File

@@ -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, 341 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 341 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, 340 providers — never stop building, OmniRoute picks the cheapest one that works. Six pillars. Never hit limits: auto-fallback across 340 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">341 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">340 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 341 providers in</text>
<text x="66" y="204" font-size="13.5" fill="#a1a1aa">Auto-fallback across 340 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

View File

@@ -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 341 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: 341 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 340 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: 340 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">341 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">340 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&#160;&#160;&#160;&#160;<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

View File

@@ -18,12 +18,31 @@ There are also two launchers — `omniroute launch` (Claude Code) and
`omniroute launch-codex` (Codex) — that spawn the CLI with the right env injected,
without writing any config at all.
Provider onboarding is available from the same local/remote context. The
API-first commands below keep management authentication separate from provider
credentials and never print a credential in structured output:
```bash
omniroute providers add glm --credential-env GLM_API_KEY --name work
omniroute providers import ./providers.json --dry-run --json
omniroute providers auth openai
omniroute providers edit <connection-id> --default-model glm/glm-5.2
omniroute providers remove <connection-id> --yes
```
For scripts, prefer `--credential-stdin` or `--credential-env`; `--credential`
is retained for controlled local use. `providers remove` requires `--yes` on a
non-interactive terminal, and all five commands honor the active context or the
global `--base-url`/`--api-key` options.
For the one-time, hand-written base setup of the two richest integrations, see the
per-tool deep dives:
- [Claude Code configuration](./CLAUDE-CODE-CONFIGURATION.md)
- [Codex CLI configuration](./CODEX-CLI-CONFIGURATION.md)
- [Remote Mode](./REMOTE-MODE.md) — drive a remote OmniRoute (VPS / Tailnet) from your laptop
- [VS Code Copilot Chat](./VSCODE-COPILOT.md) — the OmniCopilot extension; it can also run these
`setup-*` commands for you from inside the editor
---
@@ -35,23 +54,23 @@ Every command honours the **active context** (set with `omniroute connect`, see
with `--remote` (or an active remote context) it fetches the catalog from that
server and writes the config locally.
| Command | Tool | What it writes | Key flags | Local vs remote |
| -------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/<name>.config.toml` — one profile per compatible text model (`codex --profile <name>`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both |
| `omniroute setup-claude` | Claude Code | `~/.claude/profiles/<name>/settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both |
| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json``omniroute` provider with every catalog model (`opencode -m omniroute/<model>`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both |
| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both |
| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both |
| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml``provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both |
| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both |
| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json``openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/<id>`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both |
| `omniroute run <target>` | Runtime launch (generic) | Nothing — spawn `claude`/`codex` with the right env and args | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--dry-run` `--json` `--port` `--profile` `--token` | Both |
| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both |
| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both |
| Command | Tool | What it writes | Key flags | Local vs remote |
| -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/<name>.config.toml` — one profile per compatible text model (`codex --profile <name>`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both |
| `omniroute setup-claude` | Claude Code | `~/.claude/profiles/<name>/settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both |
| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json``omniroute` provider with every catalog model (`opencode -m omniroute/<model>`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both |
| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both |
| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both |
| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml``provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both |
| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both |
| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json``openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/<id>`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both |
| `omniroute run <target>` | Runtime launch (generic) | Nothing — spawn `claude`/`codex`/`aider`/`goose`/`opencode`/`qwen`/`gemini` with the right env and args; Qwen and Gemini use a temporary isolated home | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--api-key-env` `--dry-run` `--json` `--port` `--profile` `--token` | Both |
| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both |
| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both |
Notes on flags (verified in the command source):
@@ -74,6 +93,20 @@ Notes on flags (verified in the command source):
a profile written by `setup-claude` / `setup-codex`, plus pass-through args for
the underlying `claude` / `codex` binary.
The interactive picker is also shared by the setup recipes:
```bash
# Pick from the active local or remote model catalog and configure the target.
omniroute configure claude
omniroute configure opencode --provider glm
omniroute configure qwen --model qwen/qwen3.8-max-preview --yes
```
`configure` currently delegates to the tested recipes for `codex`, `claude`,
`opencode`, `qwen`, `aider`, `goose`, `cline`, `continue`, and `kilo`. IDE-only,
MITM, and guide-only catalog entries remain explicit `setup-*`/manual flows and
are not presented as launchable targets.
> `setup-opencode` is the **lightweight openai-compatible** OpenCode integration.
> There is also a richer plugin integration — `omniroute setup opencode` — which
> installs `@omniroute/opencode-plugin`. They are different commands; the table
@@ -116,6 +149,11 @@ omniroute launch-codex # Codex CLI → local OmniRoute
omniroute launch-codex --profile glm52
omniroute run claude --model openai/gpt-5.4
omniroute run codex --model openai/gpt-5.4 --dry-run --json
omniroute run aider --model glm/glm-5.2 -- --message "reply OK"
omniroute run goose --model glm/glm-5.2
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "reply OK"
# Explicit command path: pass through whatever comes after --
omniroute run claude -- --print-system-prompt "review this diff"
@@ -171,6 +209,7 @@ tool expects (verified in the command source):
| `setup-claude` (`ANTHROPIC_BASE_URL`), `launch` | root | No — Claude Code appends `/v1/messages` |
| `setup-codex`, `launch-codex` (`model_providers.omniroute.base_url`) | with `/v1` | Yes |
| `setup-qwen` (`modelProviders.openai[].baseUrl`) | with `/v1` | Yes |
| `run gemini` (`GOOGLE_GEMINI_BASE_URL`) | root | No — the SDK appends `/v1beta/models/…` |
---
@@ -200,6 +239,56 @@ outdated), `--apply` (install without prompting), `--changelog`, `--no-backup`,
---
## Google Gemini CLI via `omniroute run gemini`
Contract verified against `@google/gemini-cli` 0.50.0: the CLI honors
`GOOGLE_GEMINI_BASE_URL` and issues `POST /v1beta/models/<model>:generateContent`
(and `:streamGenerateContent?alt=sse`) against it — exactly OmniRoute's native
Gemini surface (`/v1beta`). `omniroute run gemini` wires that automatically:
- `GOOGLE_GEMINI_BASE_URL` → the active OmniRoute base URL (root, no `/v1`);
- `GEMINI_API_KEY` → the resolved OmniRoute credential (option/env/context);
- a **temporary isolated `GEMINI_CLI_HOME`** whose `.gemini/settings.json`
selects `gemini-api-key` auth, so a stored Google OAuth session (Code Assist)
never overrides the OmniRoute-directed launch — removed after exit;
- `--model <id>` injection from `--provider`/`--model`.
```bash
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "hello"
```
Gemini's workspace-trust guard still applies in headless mode — pass
`--skip-trust` (or trust the directory interactively) yourself; the launcher
deliberately does not bypass it. This launcher is distinct from the **ACP
registration** (`src/lib/acp/registry.ts`, `gemini --acp`), which remains the
agent-protocol integration for `/dashboard/acp-agents`.
---
## Real smoke sweep (opt-in)
Deterministic launch-plan regression runs in CI (`tests/unit/cli/run-command.test.ts`,
`tests/unit/cli/run-execution.test.ts`). To validate the REAL binaries against a REAL
OmniRoute server, an opt-in harness exists at
`tests/integration/upstream-cli-smoke.int.test.ts`. It never runs automatically
(every sub-test skips unless `RUN_CLI_SMOKE=1`), passes the credential by env-var
NAME (never by value), redacts key-shaped strings from any recorded output, skips
targets whose binary is not installed, and classifies failures as
auth / upstream / config instead of a bare boolean:
```bash
RUN_CLI_SMOKE=1 \
OMNIROUTE_SMOKE_BASE_URL="http://localhost:20128" \
OMNIROUTE_SMOKE_MODEL="<provider/model>" \
OMNIROUTE_SMOKE_API_KEY_ENV="OMNIROUTE_API_KEY" \
node --import tsx/esm --test tests/integration/upstream-cli-smoke.int.test.ts
```
Optional: `OMNIROUTE_SMOKE_TARGETS="codex,opencode,qwen"` restricts the sweep;
`OMNIROUTE_SMOKE_TIMEOUT_MS` overrides the 120s per-target timeout.
---
## See also
- [Claude Code configuration](./CLAUDE-CODE-CONFIGURATION.md) — the deeper Claude Code guide

View File

@@ -10,6 +10,15 @@ Complete guide for using the Codex CLI pointed at OmniRoute as an OpenAI-compati
---
> **TOML is the only effective format.** Modern Codex reads `~/.codex/config.toml`
> exclusively (verified against codex-cli 0.147.0: `codex --help` documents
> `-c/--config` overrides "loaded from `~/.codex/config.toml`"). The old
> `~/.codex/config.yaml` belonged to the legacy npm CLI and is silently ignored.
> The dashboard generator (`/api/cli-tools/apply`, tool `codex`) writes TOML with a
> conservative merge — existing keys and other provider blocks are preserved, the
> API key stays in `OMNIROUTE_API_KEY` (never in the file), and a leftover legacy
> `config.yaml` is reported as a migration note without being touched.
## Ready-to-paste config.toml
Replace `<YOUR_HOST>` and `<YOUR_KEY>` with your values:

View File

@@ -329,10 +329,13 @@ prefix). Traefik should route `PathPrefix(`/omniroute`)` to the container withou
`StripPrefix`, so Next.js receives `/omniroute/...` and serves assets from
`/omniroute/_next/...`.
The Docker healthcheck probes `/api/monitoring/health` prefixed with the active
`OMNIROUTE_BASE_PATH`. That path is a **deep** check (DB + monitoring summary). It is
appropriate for Dockers infrequent `HEALTHCHECK`, but **not** for Kubernetes
`livenessProbe` intervals.
The Docker healthcheck probes the lightweight `/healthz` lifecycle endpoint prefixed
with the active `OMNIROUTE_BASE_PATH`. `/api/monitoring/health` remains available for
human/dashboard diagnostics; to point the container HEALTHCHECK back at it (for example
for deep health enforcement), set `OMNIROUTE_HEALTHCHECK_PATH=/api/monitoring/health`.
That path is a **deep** check (DB + monitoring summary) — appropriate for Docker's
infrequent `HEALTHCHECK` if you opt back in, but **not** for Kubernetes `livenessProbe`
intervals.
For orchestrators (Kubernetes, Nomad, etc.):

View File

@@ -271,13 +271,41 @@ omniroute configure codex
# non-interactive
omniroute configure codex --provider glm --model glm/glm-5.2 --name glm52
# keep a frequently used model at the top of the interactive picker
omniroute configure codex --provider glm --model glm/glm-5.2 --favorite --yes
```
The picker keeps only model IDs (never URLs or credentials) in the local
`model-preferences.json` file, scoped by context and CLI target. Favorites are
shown before recent selections; use `--unfavorite` to remove a selected model
from that context/target list.
The written profile references the inference key by env var
(`OMNIROUTE_API_KEY`) — the secret is never written to disk. For the one-time
base Codex setup (the `[model_providers.omniroute]` block), see
[CODEX-CLI-CONFIGURATION.md](./CODEX-CLI-CONFIGURATION.md).
### Launching a CLI against the remote (no config written)
`omniroute run <target>` also honours the active context: the remote base URL
and the context credential are injected into the spawned process only.
```bash
omniroute connect 192.168.0.15
omniroute run claude --model openai/gpt-5.4 # Claude Code → remote
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "hello"
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
# Preview exactly what would be spawned (env KEY NAMES only, never values):
omniroute run codex --dry-run --json
```
Targets: `claude`, `codex`, `aider`, `goose`, `opencode`, `qwen`, `gemini`
(single source: `bin/cli/cli-manifest.mjs`). Qwen and Gemini run with a
temporary isolated home that is removed on exit, so the launch never touches —
or leaks into — your personal tool configuration.
### Per-CLI setup commands
Each supported CLI has a remote-aware setup command (all honour the active
@@ -360,14 +388,20 @@ omniroute contexts remove stg --yes
> revoke the token on the server with `omniroute tokens revoke <id>` to actually
> kill access.
**Export / import** contexts (e.g. to move them between machines — secrets included,
so handle the file carefully):
**Export / import** contexts (e.g. to move them between machines). New contexts persist
only a keychain reference; credentials are not copied into the export when the OS
keychain is available:
```bash
omniroute contexts export --out contexts.json # default: stdout
omniroute contexts import contexts.json # overwrite; --merge to keep existing
omniroute contexts migrate --yes # move legacy plaintext tokens to keychain
```
On headless systems without a usable OS keychain, the CLI falls back to
`config.json` with mode `0600` and prints a one-time warning. Treat exports from
that fallback (and any legacy config before migration) as secret material.
---
## Quick end-to-end check
@@ -409,8 +443,12 @@ omniroute contexts remove 192-168-0-15 --yes # drop the local context (even if
- `omniroute connect` reuses the login brute-force lockout + audit logging.
- Prefer HTTPS or a Tailnet for the transport; a bare host defaults to `http://`
for LAN/Tailscale convenience — pass a full `https://…` URL for TLS.
- The local context file is `~/.omniroute/config.json` (`chmod 600`); tokens are
never printed in logs (masked to a prefix).
- The preferred local context file is `~/.omniroute/config.json` (`chmod 600`)
containing only a `credentialRef`; the token itself is stored in the OS
keychain (`keytar`) and is never printed in logs. Headless installs without a
working native keychain use the same `0600` file as an explicit fallback and
emit a warning once. Use `omniroute contexts migrate --yes` after installing a
keychain backend.
---

View File

@@ -56,6 +56,8 @@ npm install
PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm run dev
```
> **Windows note:** By default, OmniRoute uses `%APPDATA%\omniroute` when the legacy `%USERPROFILE%\.omniroute` directory is not present. Set `DATA_DIR` to choose a different data-directory location.
> **Note:** `npm install` auto-generates `.env` from `.env.example` on first run. Subsequent installs will not overwrite an existing `.env`, so customizations are preserved. To re-seed, delete `.env` before re-running.
### Docker

View File

@@ -478,7 +478,7 @@ If a provider repeatedly enters OPEN state:
### "Unsupported model" error
- Ensure you're using the correct prefix: `deepgram/nova-3` or `assemblyai/best`
- Use a model id whose first segment is a provider you have credentials for (`openai/whisper-1`, `openrouter/deepgram/nova-3`). Bare `deepgram/nova-3` requires a native Deepgram key.
- Verify the provider is connected in **Dashboard → Providers**
### Transcription returns empty or fails

View File

@@ -948,9 +948,12 @@ Content-Type: multipart/form-data
curl -X POST http://localhost:20128/v1/audio/transcriptions \
-H "Authorization: Bearer your-api-key" \
-F "file=@audio.mp3" \
-F "model=deepgram/nova-3"
-F "model=openai/whisper-1"
```
`deepgram/nova-3` is the native Deepgram route and needs a Deepgram API key.
If only OpenRouter is configured, use `openrouter/deepgram/nova-3`.
**Speech-to-Text (transcription)** providers:
- `openai/` (whisper-compatible)

View File

@@ -0,0 +1,138 @@
---
title: "VS Code Copilot Chat — OmniCopilot extension"
version: 3.8.50
lastUpdated: 2026-08-18
---
# VS Code Copilot Chat — OmniCopilot extension
**OmniCopilot** puts every model your OmniRoute serves into the *native* GitHub Copilot Chat
model picker. No second sidebar, no separate chat UI — Copilot's agent mode, tool calling,
MCP servers and custom instructions all keep working, just running on the model you pick.
| | |
| --- | --- |
| **Install (VS Code)** | [Marketplace → `diegosouzapw.omnicopilot`](https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot) |
| **Install (forks)** | [Open VSX](https://open-vsx.org/extension/diegosouzapw/omnicopilot) — Cursor, Windsurf, VSCodium, Theia, code-server, Gitpod, Antigravity, Kiro |
| **Source / issues** | [github.com/diegosouzapw/OmniCopilot](https://github.com/diegosouzapw/OmniCopilot) (MIT) |
| **Requires** | VS Code 1.104+ |
> **No Copilot subscription needed.** Since VS Code 1.122 a language-model provider works
> without a GitHub sign-in and without any Copilot plan. Inline completions and
> embeddings-based features stay outside the provider API and still require Copilot.
---
## Setup
1. **Run OmniRoute**`npm install -g omniroute && omniroute` (dashboard on `http://localhost:20128`).
2. **Install the extension** — search "OmniRoute" in the Extensions view.
3. **Pick a model** — Copilot Chat → model picker → **Manage Models…****OmniRoute**, then tick
what you want.
Nothing to configure when OmniRoute runs on the default port. For a remote instance, open the
**OmniRoute icon in the Activity Bar** (or run `OmniRoute: Manage Connection`) and set:
- **Server URL** — the server root, e.g. `http://192.168.0.15:20128`. The `/v1` suffix is
appended by the extension; do not include it.
- **API key** — only when the server sets `REQUIRE_API_KEY`. Stored in the OS keychain via VS
Code SecretStorage, never in `settings.json`.
---
## What the picker will show
The extension does not show the raw `GET /v1/models` payload — it shapes it, and the count you
see is lower than the catalog size for two deliberate reasons.
### It asks for one id per model
`MODELS_CATALOG_PREFIX_MODE` defaults to **`dual`**, which advertises every model twice — once
under the short alias prefix and once under the canonical provider prefix — so older client
configs keep resolving either form:
```
cc/claude-sonnet-4-6 ← alias prefix
claude/claude-sonnet-4-6 ← canonical prefix, same model
```
The extension requests **`GET /v1/models?prefix=alias`** so one id arrives per model, without
changing the server-wide setting for your other clients. On a reference instance this collapsed
**2345 entries to 1396 — 949 duplicates, zero models lost.**
If you would rather fix it server-wide for *every* client, set the
`MODELS_CATALOG_PREFIX_MODE` feature flag to `alias` in the dashboard. See
[API_REFERENCE → prefix](../reference/API_REFERENCE.md#model-id-prefixes-prefix) for the
query parameter and the warning about `canonical`.
### It hides models that cannot chat
The catalog also lists image, video, audio, rerank, embedding and moderation models. Those are
rejected on a chat request anyway:
```
HTTP 400 — Model '<id>' is an image-generation model and cannot be used on
/v1/chat/completions. Use POST /v1/images/generations instead.
```
so they are filtered out by their `type` field before reaching the picker. **Responses-API
models are kept** — every Codex / GPT-5.x entry advertises `supported_endpoints: ["responses"]`,
and OmniRoute translates those for `/v1/chat/completions`, so they are perfectly usable.
### Providers you never configured
The catalog lists models from providers with an **active connection** *plus* every **noAuth**
provider — the keyless ones that make up much of the free tier. That is intentional. To hide
them, add them to `blockedProviders` in the dashboard settings; nothing changes in the
extension.
---
## Dashboard inside a VS Code tab
`omnicopilot.dashboardOpen: "editor"` renders the OmniRoute dashboard in an editor tab via the
Simple Browser instead of an external browser. Embedding is **opt-in on the server**: start
OmniRoute with
```bash
DASHBOARD_ALLOW_EMBED=vscode omniroute
```
which serves the HTML pages with `frame-ancestors 'self' vscode-webview:` instead of the default
`frame-ancestors 'none'` + `X-Frame-Options: DENY`. The API surface (`/api`, `/v1`, `/v1beta`,
`/a2a`, `/healthz`) keeps the strict headers either way. Without the variable the page refuses to
frame and the extension falls back to the external browser — nothing breaks. See
[`ENVIRONMENT.md`](../reference/ENVIRONMENT.md) and issue
[#10273](https://github.com/diegosouzapw/OmniRoute/issues/10273).
---
## Configuring your other tools from inside VS Code
**`OmniRoute: Configure Coding CLI`** drives the `omniroute` CLI to write ready-to-use profiles
for Codex CLI, Claude Code, Cline, Continue, Cursor, Aider, OpenCode, Goose, Crush, Qwen Code,
Kilo and Roo — the same configs described in
[`CLI-INTEGRATIONS.md`](CLI-INTEGRATIONS.md). The API key is handed to the CLI through the
`OMNIROUTE_API_KEY` environment variable, never on the command line.
---
## Troubleshooting
| Symptom | Cause / fix |
| --- | --- |
| No OmniRoute models in the picker | Server unreachable. The status-bar dot goes grey; run `OmniRoute: Check Connection`. Discovery is silent by design and contributes no models rather than prompting. |
| Every model appears twice | You are on an OmniCopilot older than 1.0.1 — update. The extension now requests `?prefix=alias`. |
| An image/audio model used to be listed and is gone | Intentional since 1.0.1 — it could never answer a chat request. |
| Panel missing from the Activity Bar | VS Code moves extra view containers into the **"…"** overflow at the bottom of the Activity Bar, and a container hidden via right-click stays hidden. Right-click the Activity Bar → tick **OmniRoute**, or open it with `OmniRoute: Manage Connection`. |
| Dashboard opens in the browser despite `editor` mode | The server is not started with `DASHBOARD_ALLOW_EMBED=vscode` (see above). The fallback is deliberate. |
| Models list is stale after changing providers | `OmniRoute: Refresh Models`, or the ↻ link in the panel. |
---
## See also
- [`CLI-INTEGRATIONS.md`](CLI-INTEGRATIONS.md) — every other coding tool
- [`REMOTE-MODE.md`](REMOTE-MODE.md) — driving a remote OmniRoute
- [`../reference/API_REFERENCE.md`](../reference/API_REFERENCE.md) — the `/v1/models` contract
- [`docs/CATALOG.md`](https://github.com/diegosouzapw/OmniCopilot/blob/main/docs/CATALOG.md) — the extension's own catalog notes

View File

@@ -16,6 +16,7 @@
"CLAUDE-CODE-CONFIGURATION",
"CODEX-CLI-CONFIGURATION",
"CLI-INTEGRATIONS",
"VSCODE-COPILOT",
"MANAGEMENT-AUTH",
"REMOTE-MODE",
"PWA_GUIDE",

View File

@@ -4,7 +4,7 @@
---
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 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 340 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
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations)
- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -169,7 +169,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 (341), model lists, pricing, routing strategies, MCP scopes
│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes
│ │ ├── contracts/ # Shared API contracts
│ │ ├── hooks/ # React hooks
│ │ ├── middleware/ # Shared middleware utilities
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
## Key Features (v3.8.50)
### Core Proxy
- **341 AI providers** with automatic format translation
- **340 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
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
@@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
## v3.8.x Highlights
- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **340-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)

View File

@@ -4,7 +4,7 @@
---
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 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 340 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
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations)
- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -169,7 +169,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 (341), model lists, pricing, routing strategies, MCP scopes
│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes
│ │ ├── contracts/ # Shared API contracts
│ │ ├── hooks/ # React hooks
│ │ ├── middleware/ # Shared middleware utilities
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
## Key Features (v3.8.50)
### Core Proxy
- **341 AI providers** with automatic format translation
- **340 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
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
@@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
## v3.8.x Highlights
- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **340-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)

View File

@@ -4,7 +4,7 @@
---
> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 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 340 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
@@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
- **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`)
- **Framework:** Next.js 16 (App Router) with TypeScript 6
- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations)
- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations)
- **State management:** Zustand (client), SQLite (server persistence)
- **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons
- **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth
@@ -169,7 +169,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 (341), model lists, pricing, routing strategies, MCP scopes
│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes
│ │ ├── contracts/ # Shared API contracts
│ │ ├── hooks/ # React hooks
│ │ ├── middleware/ # Shared middleware utilities
@@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
## Key Features (v3.8.50)
### Core Proxy
- **341 AI providers** with automatic format translation
- **340 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
@@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`.
5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module.
6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches.
@@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool
## v3.8.x Highlights
- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add
- **340-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)

Some files were not shown because too many files have changed in this diff Show More