Compare commits

..

1 Commits

2549 changed files with 37630 additions and 197740 deletions

View File

@@ -87,10 +87,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Used by: src/shared/utils/rateLimiter.ts
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
# REDIS_URL=redis://localhost:6379
# Namespace prefix for ALL OmniRoute Redis keys (rate limiter + auth cache +
# quota store). Prevents key collisions when OmniRoute shares a Redis instance
# with other apps (e.g. on 127.0.0.1:6379). Default when unset: omniroute:
# REDIS_KEY_PREFIX=omniroute:
# Host interface docker-compose publishes the Redis sidecar on.
# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT
# `requirepass`, and app containers reach it over the compose network
@@ -132,11 +128,6 @@ PORT=20128
# Optional: set the public origin *with* the same path so OAuth and display URLs
# stay consistent without relying on window.location.origin alone:
# NEXT_PUBLIC_BASE_URL=https://host/omniroute
#
# Explicit path probed by the container health check. Unset, the probe derives it
# from OMNIROUTE_BASE_PATH; setting it opts back into the deep monitoring endpoint.
# Used by: scripts/dev/healthcheck.mjs
# OMNIROUTE_HEALTHCHECK_PATH=/api/monitoring/health
# Opt-in iframe embedding of the OmniRoute HTML pages (issue #10273). Off by default:
# every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`, which is why the
@@ -147,8 +138,6 @@ PORT=20128
# (/api, /v1, /v1beta, /a2a, /healthz and the root-level aliases) keeps the strict
# headers regardless. Only `vscode` is recognised; `1`/`true` do NOT enable it.
# Used by: next.config.mjs via scripts/build/dashboardEmbed.mjs — build-time, rebuild after changing.
# Docker: pass it as a build arg (`docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode`);
# setting it on an already-built server or image does nothing.
# DASHBOARD_ALLOW_EMBED=vscode
# Split-port mode: serve Dashboard and API on separate ports for network isolation.
@@ -233,15 +222,6 @@ PORT=20128
# unaffected by this dev-only flag).
OMNIROUTE_USE_TURBOPACK=1
# Disable systemd sd_notify (Type=notify / WatchdogSec=) even when running
# under a systemd unit with NOTIFY_SOCKET set.
# Used by: scripts/dev/systemd-notify.mjs. Set to 1 to disable.
# OMNIROUTE_DISABLE_SD_NOTIFY=1
# Injected by systemd when running under a service unit (sd_notify protocol).
# Read by scripts/dev/systemd-notify.mjs — never set this yourself.
# NOTIFY_SOCKET=/run/systemd/notify
# Skip the SQLite integrity health check on startup (faster boot on large DBs).
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip.
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
@@ -266,22 +246,12 @@ 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
# so a missing/corrupt cache never breaks tab-completion.
# OMNIROUTE_DEBUG_COMPLETION=1
# Set to 1 to print per-request timing diagnostics from the CLI quota commands
# to stderr (`[omniroute] GET <path> completed in Nms`).
# Used by: bin/cli/commands/quota.mjs
# OMNIROUTE_DEBUG=1
# Docker production port mappings (docker-compose.prod.yml only).
# These set the HOST-side published ports. Container ports use PORT/API_PORT.
# PROD_DASHBOARD_PORT=20130
@@ -376,8 +346,9 @@ ALLOW_API_KEY_REVEAL=false
# NO_LOG_API_KEY_IDS=key_abc123,key_def456
# Fallback per-day request budget applied to API keys whose `rate_limits`
# column is null. Default (unset/empty) is unlimited (no implicit caps).
# Malformed values preserve the legacy 1000/day, 5000/week, 20000/month windows.
# column is null. Default (unset/empty/malformed) preserves the legacy
# 1000/day, 5000/week, 20000/month windows so existing deployments do not
# silently lose rate limiting on upgrade.
# Set explicitly to "0" to opt out entirely (unlimited fallback). Any
# positive integer N enables N/day, 5N/week, 20N/month.
# Used by: src/shared/utils/apiKeyPolicy.ts — checkRateLimit() fallback.
@@ -399,16 +370,6 @@ 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.
@@ -420,15 +381,6 @@ ALLOW_API_KEY_REVEAL=false
# by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive
# value only on memory-constrained deployments that need a hard ceiling.
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0
# Skip OmniRoute's local context-window and max-input-token check for direct
# single-model requests. Default: false (dangerous opt-in).
# The upstream provider still enforces its real limits, so enabling this can
# replace an early OmniRoute 400 with an upstream context-length error.
# Prompt compression and the model's own output-token cap remain active.
# Also configurable from Dashboard > Settings > Feature Flags; no restart is
# required. Used by: src/shared/utils/featureFlags.ts and open-sse/handlers/chatCore.ts.
# DISABLE_CONTEXT_WINDOW_CHECKS=false
# How long a heavy request waits for heavyweight capacity before a retryable 503.
# A short bounded wait serializes agent bursts instead of an instant 503; 0 = instant.
# Default 2000 (2s).
@@ -441,12 +393,6 @@ 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
@@ -717,11 +663,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# ALL_PROXY=socks5://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1
# Pin the echo-IP target used by proxy egress probes. Unset, the probe tries
# api64.ipify.org then api4.ipify.org so IPv4-only tunnels are not reported dead.
# Used by: src/lib/proxyEchoTarget.ts.
# OMNIROUTE_PROXY_ECHO_URL=https://api4.ipify.org?format=json
# Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher.
# Long-lived SSE streams such as Codex /v1/responses need more than one
# connection when multiple requests share the same account-level proxy.
@@ -773,16 +714,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Allow OmniRoute to write CLI config files (token refresh, etc.).
# CLI_ALLOW_CONFIG_WRITES=true
# Force container detection on (1/true) or off (0/false). Leave unset for auto-detect
# via /.dockerenv, /run/.containerenv, cgroup markers, or KUBERNETES_SERVICE_HOST.
# Used by: src/shared/utils/containerEnv.ts — gates ephemeral-home CLI config writes.
# OMNIROUTE_CONTAINER=1
# Allow CLI-tool config writes into an unmounted container path anyway (default off).
# Prefer host-side `omniroute configure` / Remote Mode, or a bind-mounted CLI_CONFIG_HOME.
# CLI equivalent: --allow-container-write. Used by: src/shared/utils/containerEnv.ts
# OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true
# Auto-sync CLI profile files after provider model discovery changes. OPT-IN, default OFF for
# both. When enabled, writes only the tool's profile files (~/.codex/*.config.toml or
# ~/.claude/profiles/<name>/settings.json); never changes the active/default config. Both also
@@ -799,43 +730,11 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_CURSOR_BIN=agent
# CLI_CLINE_BIN=cline
# CLI_CONTINUE_BIN=cn
# CLI_QODER_BIN=qodercli
# CLI_QODER_BIN=qoder
# CLI_QWEN_BIN=qwen
# CLI_AIDER_BIN=aider
# CLI_GOOSE_BIN=goose
# CLI_GEMINI_BIN=gemini
# CLI_KILO_BIN=kilocode
# CLI_OPENCODE_BIN=opencode
# CLI_HERMES_BIN=hermes
# CLI_FORGE_BIN=forge
# CLI_JCODE_BIN=jcode
# CLI_DEEPSEEK_TUI_BIN=deepseek-tui
# CLI_CODEWHALE_BIN=codewhale
# CLI_SMELT_BIN=smelt
# CLI_PI_BIN=pi
# CLI_CRUSH_BIN=crush
# CLI_OMP_BIN=omp
# CLI_LETTA_BIN=letta
# Windsurf has no default binary — set this to enable binary detection for it.
# CLI_WINDSURF_BIN=windsurf
# CLI_AUGGIE_BIN=auggie
# AUGGIE_BIN=auggie
# ── ZCode (Z.ai GLM coding-plan CLI) local provider ──
# The local "zcode" provider talks to the authenticated ZCode app-server over a
# custom framed stdio protocol. Overrides below tune that stdio lifecycle.
# ZCODE_BIN=zcode
# ZCODE_ARGS=["--some-flag"]
# ZCODE_CWD=
# ZCODE_PROVIDER_ID=builtin:zai-coding-plan
# ZCODE_SERVER_RUNTIME_ROOT=~/.zcode/server
# ZCODE_SERVER_NODE=~/.zcode/server/node
# ZCODE_SERVER_ENTRY=~/.zcode/server/zcode-server.cjs
# ZCODE_STARTUP_TIMEOUT_MS=10000
# ZCODE_RPC_TIMEOUT_MS=30000
# ZCODE_TURN_TIMEOUT_MS=120000
# ZCODE_POLL_INTERVAL_MS=250
# Override the Hermes Agent home directory (where OmniRoute reads/writes the
# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets
# on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset.
@@ -876,13 +775,6 @@ 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
@@ -902,21 +794,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Set to 0/false/off to skip compression entirely. Default: rtk
# OMNIROUTE_MCP_DESCRIPTION_COMPRESSION=rtk
# Abort budget (ms) for MCP-server internal management reads (health, resilience,
# combos, quota, usage). Default: 10000. Used by: open-sse/mcp-server/fetchTimeout.ts
# OMNIROUTE_MCP_FETCH_TIMEOUT_MS=10000
# Abort budget (ms) for MCP hops that wait on a provider (route_request, web_search,
# web_fetch). Default: 60000. Used by: open-sse/mcp-server/fetchTimeout.ts
# OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS=60000
# Model catalog sync interval in hours.
# Used by: src/shared/services/modelSyncScheduler.ts — periodic model refresh.
# Default: 24
# MODEL_SYNC_INTERVAL_HOURS=24
# Provider limits sync interval in minutes (rate limit windows, quotas).
# Used by: src/lib/usage/providerLimits.ts — polls provider health endpoints.
# Used by: src/server-init.ts — polls provider health endpoints.
# Default: 70
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
@@ -1067,10 +951,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: src/lib/db/core.ts::getDbHealthCheckIntervalMs().
#OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS=21600000
# WAL truncate cadence override (ms). Set to 0 to disable. Default: 21600000 (6h).
# Used by: src/lib/db/core.ts::getWalTruncateIntervalMs().
#OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS=21600000
# Skip the Redis-backed auth cache used by API key lookups (forces DB reads).
# Used by: src/lib/db/apiKeys.ts. Set to 1 to disable. Default: enabled.
#OMNIROUTE_DISABLE_REDIS_AUTH_CACHE=0
@@ -1348,14 +1228,6 @@ 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.
@@ -1386,14 +1258,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# FETCH_BODY_TIMEOUT_MS=600000 # Time to receive full response body
# FETCH_CONNECT_TIMEOUT_MS=30000 # TCP connection establishment (default: 30s)
# FETCH_KEEPALIVE_TIMEOUT_MS=4000 # Keep-alive socket idle timeout (default: 4s)
# OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS=30000 # Bounded response-start window per direct
# # (no-proxy) attempt (#10214). A silently-dropped
# # pooled keep-alive socket surfaces no transport
# # error, so without this bound a direct request can
# # stall until undici's headersTimeout (600s) or the
# # caller's deadline; on expiry the request retries
# # once on a fresh no-keep-alive socket. 0 disables
# # the bound (default: 30000 = 30s).
# Default timeout (ms) for src/shared/utils/fetchTimeout.ts. Acts as the
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
@@ -1448,14 +1312,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
# ── Perplexity web: built-in-search hint ──
# Used by: open-sse/executors/perplexity-web/protocol.ts — appends "You have
# built-in web search. Answer questions directly using search results." to the
# caller's system message. Off by default: Perplexity's answer engine searches
# anyway, and for coding clients the sentence leaks into replies as
# meta-commentary. Set to 1/true/yes/on to restore the old behavior.
# OMNIROUTE_PPLX_SEARCH_HINT=0
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
@@ -1486,20 +1342,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OMNIROUTE_BROWSER_POOL=on
# WEB_COOKIE_USE_BROWSER=0
# ── Kimi Web (international kimi.ai Connect-RPC) ──
# Used by: open-sse/executors/kimi-web.ts. Override the base/chat URLs only if
# you need a mirror or proxy endpoint; defaults target https://www.kimi.ai with
# the Connect-RPC chat path /apiv2/kimi.gateway.chat.v1.ChatService/Chat.
# KIMI_WEB_BASE_URL=https://www.kimi.ai
# KIMI_WEB_CHAT_URL=https://www.kimi.ai/apiv2/kimi.gateway.chat.v1.ChatService/Chat
# When OIDC is enabled, disable password login so users can only authenticate
# via OIDC Single Sign-On. The bare alias OIDC_DISABLE_PASSWORD_LOGIN is also
# accepted; the Dashboard Feature Flag takes precedence. Used by:
# src/app/api/auth/login/route.ts, src/app/api/settings/require-login/route.ts.
# OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN=false
# OIDC_DISABLE_PASSWORD_LOGIN=false
# ── Adobe Firefly browser sign-in (system Chrome/Edge CDP) ──
# Used by: open-sse/services/adobeFireflyBrowserLogin.ts. The Firefly login
# flow drives a real, system-installed Chrome or Microsoft Edge via CDP so the
@@ -1677,10 +1519,6 @@ APP_LOG_TO_FILE=true
# Default: 100000
# PROXY_LOGS_TABLE_MAX_ROWS=100000
# Include client/egress IPs and account prefixes in [ProxyEgress] console logs.
# Default: false (the dashboard/database proxy-log records retain full details).
# PROXY_LOG_INCLUDE_IPS=false
# ═══════════════════════════════════════════════════════════════════════════════
# 17. MEMORY OPTIMIZATION (Low-RAM / Docker)
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1781,16 +1619,6 @@ 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
@@ -1899,12 +1727,6 @@ 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).
@@ -1944,6 +1766,10 @@ APP_LOG_TO_FILE=true
# Default: 300000 (5 minutes)
# SEARCH_CACHE_TTL_MS=300000
# ── OpenAI-compatible multi-connection ──
# Allow multiple simultaneous connections per OpenAI-compatible provider node.
# Used by: src/app/api/providers/route.ts
# ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE=false
# ── CC-compatible provider (experimental) ──
# Enable the Claude Code compatible provider endpoint.
@@ -2039,27 +1865,10 @@ APP_LOG_TO_FILE=true
# Reachability probe target for the scheduler and the auto-test endpoint.
# Point it at an internal/self-hosted URL to avoid the public default.
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
# Probes started at once per batch, for the scheduler and the auto-test endpoint.
# Floored at 1 and capped at 50. Default: 10.
# PROXY_HEALTH_TEST_CONCURRENCY=10
# Delay in ms between two probe departures inside a batch. Without it the whole batch
# leaves at once and a shared egress IP can trip a rate-limited target. 0 disables the
# spacing; capped at 5000. Default: 100.
# PROXY_HEALTH_TEST_STAGGER_MS=100
# Set "false" to stop probing the real host of a proxy's assigned provider (GET /models,
# no API key) and always use the generic target above instead. Default: enabled.
# PROXY_HEALTH_USE_PROVIDER_TARGET=true
# Set "true" to let the scheduler auto-remove proxies after repeated failures.
# PROXY_AUTO_REMOVE=false
# Consecutive failures before an auto-remove fires. Default: 3.
# PROXY_AUTO_REMOVE_AFTER=3
# Set "true" to let the scheduler auto-disable (status "dead") proxies after
# repeated failures instead of deleting them. Non-destructive alternative to
# PROXY_AUTO_REMOVE — the row stays in the registry, drops out of pool/rotation
# resolution immediately, and is automatically re-activated once it starts
# answering probes again. Shares the PROXY_AUTO_REMOVE_AFTER threshold above.
# If both PROXY_AUTO_REMOVE and PROXY_AUTO_DISABLE are "true", auto-remove wins.
# PROXY_AUTO_DISABLE=false
# Let automated reachability probes (the scheduler + the "Test All" button) WRITE
# a proxy's status. Default "false": probes are read-only and never deactivate a
# proxy — only the operator sets active/inactive (a flaky probe must not strand an
@@ -2191,19 +2000,6 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: detect local install, else pin.
# CURSOR_AGENT_CLI_VERSION=2026.07.08-0c04a8a
# Path to the Cursor Agent binary used for image generation.
# Used by: open-sse/handlers/imageGeneration/providers (CURSOR_IMAGE.md).
# CURSOR_AGENT_BIN=/path/to/agent
# Cursor image-generation wall clock (ms). Default: 210000.
# CURSOR_IMG_TIMEOUT_MS=210000
# Shared-seat concurrency gate for Cursor image jobs. Default: 2.
# CURSOR_IMG_MAX_CONCURRENT=2
# Override Cursor CLI --model for image jobs. Default: request model / auto.
# CURSOR_IMG_MODEL=auto
# Cursor Agent CLI data directory override (versions live under <dir>/versions/).
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: ~/.local/share/cursor-agent (unix)
# or %LOCALAPPDATA%\cursor-agent (win32). Official agent CLI also honors this var.
@@ -2577,17 +2373,6 @@ APP_LOG_TO_FILE=true
# intended to be published as `omniroute-secure`. See SECURITY.md.
# OMNIROUTE_BUILD_PROFILE=full
# Override the standalone build output directory consumed by the post-build
# colocation step. Default: the real Next.js standalone output under .build/.
# Used by: scripts/build/colocate-standalone.mjs (build tooling, not runtime).
# OMNIROUTE_STANDALONE_DIR=
# Skip emitting `.tar.gz` tarballs during optional-pack staging for the Electron
# standalone tree (pack directories + optional-packs.index.json are still produced).
# Used by the desktop release workflow to trim artifact upload size.
# Default (when unset): 1 (tarballs emitted). Set to 0 to disable.
# OMNIROUTE_OPTIONAL_PACK_TAR=1
# Electron smoke harness (used by scripts/dev/smoke-electron-packaged.mjs).
# ELECTRON_SMOKE_URL=http://127.0.0.1:20128/login
# ELECTRON_SMOKE_TIMEOUT_MS=45000
@@ -2596,8 +2381,6 @@ APP_LOG_TO_FILE=true
# ELECTRON_SMOKE_DATA_DIR=
# ELECTRON_SMOKE_KEEP_DATA=0
# ELECTRON_SMOKE_STREAM_LOGS=0
# #7592: second launch against the same DATA_DIR must pick the native driver.
# ELECTRON_SMOKE_COLD_RESTART=0
# Playground Studio
# Default model used by the improve-prompt route (optional; falls back to model in request body).
@@ -2937,14 +2720,18 @@ QUOTA_STORE_DRIVER=sqlite
# Minimum spacing between submissions and the extra pause after every third success.
# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000
# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000
# Browser used by Adobe Firefly renewal. True headless is debug-only: Adobe
# colligo normally rejects risk tokens minted without a headed browser.
# Used by: open-sse/services/adobeFireflyBrowserLogin.ts
# Chrome CDP runtime used by Adobe Firefly renewal. True headless is debug-only:
# Adobe colligo normally rejects risk tokens minted without a headed browser.
# ADOBE_FIREFLY_CHROME_CDP_PORT=9334
# ADOBE_FIREFLY_CHROME_VISIBLE=0
# ADOBE_FIREFLY_CHROME_HEADED=0 # Legacy alias for ADOBE_FIREFLY_CHROME_VISIBLE=1
# ADOBE_FIREFLY_CHROME_HEADLESS=0
# The CDP-attached Chrome runtime (adobeFireflyChromeRuntime.ts) was removed in
# #9255 along with its knobs — ADOBE_FIREFLY_CHROME_CDP_PORT, _VISIBLE, _HEADED,
# _PING, _FORCE_RESTART, ADOBE_FIREFLY_LOGIN_WAIT_MS and _FORTER_WAIT_MS are read
# nowhere and have no effect.
# ADOBE_FIREFLY_CHROME_FORCE_RESTART=0
# ADOBE_FIREFLY_CHROME_PING=auto
# ADOBE_FIREFLY_LOGIN_WAIT_MS=0
# ADOBE_FIREFLY_FORTER_WAIT_MS=45000
# Optional absolute Chrome executable; auto-detected when unset.
# CHROME_PATH=
# Telegram Mini App bridge. The update endpoint remains disabled while the bot
# token is unset. Used by: src/lib/telegram/* and src/app/api/telegram/update/route.ts.
@@ -2967,13 +2754,3 @@ 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

View File

@@ -50,13 +50,13 @@ updates:
# bumps; majors here need their own PR and a deliberate migration review.
- dependency-name: "ioredis"
update-types: ["version-update:semver-major"]
# @huggingface/transformers is VPS-validated at ^4.2.0 (migrated intentionally in
# #9962). It is load-bearing for the LLMLingua ONNX compression engine (open-sse/
# services/compression/engines/llmlingua/ — @atjsh/llmlingua-2@2.0.5 peers on
# "@huggingface/transformers": "^3.5.2 || ^4.0.0") and for local memory embeddings
# (src/lib/memory/embedding/transformersLocal.ts). Further majors must be re-validated
# on the VPS — so keep auto-bumps frozen (no update-types = ignore every version).
# Migrate it intentionally, not via dependabot (#4050).
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
# and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts),
# and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must
# be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore
# every version). Migrate it intentionally, not via dependabot (#4050).
- dependency-name: "@huggingface/transformers"
- package-ecosystem: "github-actions"

View File

@@ -697,12 +697,11 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
needs: build
# WS1.5 (v3.8.49 plan): the Electron native-module path previously executed for
# WS1.5 (v3.8.49 plan): the Electron rebuild/spawn path previously executed for
# the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned
# without shell, CVE-2024-27980 behavior change) could only surface at release.
# windows-latest runs prepare:bundle (better-sqlite3 prebuild verification since
# v13 — the node-gyp rebuild is gone) per release PR; ubuntu keeps the full
# pack + headless smoke.
# windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release
# PR; ubuntu keeps the full pack + headless smoke.
strategy:
fail-fast: false
matrix:
@@ -739,7 +738,7 @@ jobs:
# precedent): its first-ever real run (2026-07-15, run 29457533565) died in
# 0.7s with the error swallowed by pwsh — bash shell captures stderr and
# continue-on-error keeps the heavy gate green while we harden it (#7336).
- name: Prepare Electron standalone (Windows prebuild verification)
- name: Prepare Electron standalone (Windows ABI rebuild + spawn path)
if: runner.os == 'Windows'
working-directory: electron
continue-on-error: true

View File

@@ -22,10 +22,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
- uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
- uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
with:
category: "/language:javascript-typescript"

View File

@@ -37,7 +37,7 @@ jobs:
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
- name: Build CLI bundle
env:
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
@@ -46,7 +46,6 @@ jobs:
env:
PORT: "20128"
INJECTION_GUARD_MODE: block
REQUIRE_API_KEY: "false"
run: |
node dist/server.js > server.log 2>&1 &
echo $! > server.pid
@@ -65,20 +64,16 @@ jobs:
# those 302s as "the API accepted a schema-violating request" and the configured-off
# 400 as "rejected a schema-compliant request". Documenting the flow in the spec is
# still right (operators need it); fuzzing it is not what this smoke is for.
# /api/auth/login has brute-force rate limiting: repeated failed logins return 429,
# which Schemathesis flags as rejection of schema-compliant requests.
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
--exclude-path-regex '^/api/auth/(oidc/|login)' \
--exclude-path-regex '^/api/auth/oidc/' \
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
--request-timeout 20 --suppress-health-check all --no-color
- name: Install promptfoo
run: npm install -g promptfoo@0.122.0
- name: promptfoo injection-guard (blocking)
env:
OMNIROUTE_URL: http://localhost:20128
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
run: promptfoo eval -c promptfooconfig.yaml --no-cache
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
- name: Stop server
if: always()
run: kill "$(cat server.pid)" || true

View File

@@ -183,55 +183,15 @@ jobs:
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
- name: Build and push BUN base platform image by digest
id: build-bun-base
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: Dockerfile.bun
target: runner-base
platforms: ${{ matrix.platform }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-bun-base-${{ matrix.arch }}
cache-to: type=gha,scope=docker-bun-base-${{ matrix.arch }},mode=max
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
- name: Build and push BUN web platform image by digest
id: build-bun-web
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: Dockerfile.bun
target: runner-web
platforms: ${{ matrix.platform }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
${{ env.GHCR_IMAGE_NAME }}
cache-from: type=gha,scope=docker-bun-web-${{ matrix.arch }}
cache-to: type=gha,scope=docker-bun-web-${{ matrix.arch }},mode=max
no-cache: false
env:
DOCKER_BUILDKIT_INLINE_CACHE: 1
- name: Export digests
env:
DIGEST_BASE: ${{ steps.build.outputs.digest }}
DIGEST_WEB: ${{ steps.build-web.outputs.digest }}
DIGEST_BUN_BASE: ${{ steps.build-bun-base.outputs.digest }}
DIGEST_BUN_WEB: ${{ steps.build-bun-web.outputs.digest }}
run: |
set -euo pipefail
mkdir -p /tmp/digests/base /tmp/digests/web /tmp/digests/bun-base /tmp/digests/bun-web
mkdir -p /tmp/digests/base /tmp/digests/web
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
touch "/tmp/digests/bun-base/${DIGEST_BUN_BASE#sha256:}"
touch "/tmp/digests/bun-web/${DIGEST_BUN_WEB#sha256:}"
- name: Upload base digests
uses: actions/upload-artifact@v7
@@ -249,22 +209,6 @@ jobs:
if-no-files-found: error
retention-days: 1
- name: Upload bun-base digests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digests-bun-base-${{ matrix.arch }}
path: /tmp/digests/bun-base/*
if-no-files-found: error
retention-days: 1
- name: Upload bun-web digests
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digests-bun-web-${{ matrix.arch }}
path: /tmp/digests/bun-web/*
if-no-files-found: error
retention-days: 1
merge:
name: Publish multi-arch manifests
needs:
@@ -319,20 +263,6 @@ jobs:
path: /tmp/digests/web
merge-multiple: true
- name: Download bun-base digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: digests-bun-base-*
path: /tmp/digests/bun-base
merge-multiple: true
- name: Download bun-web digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: digests-bun-web-*
path: /tmp/digests/bun-web
merge-multiple: true
- name: Create Docker Hub manifest
run: |
set -euo pipefail
@@ -356,8 +286,6 @@ jobs:
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base
create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web
- name: Create GHCR manifest
run: |
@@ -382,8 +310,6 @@ jobs:
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web
- name: Inspect image
if: needs.prepare.outputs.version != 'main'
@@ -446,7 +372,7 @@ jobs:
- name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4.37.7
uses: github/codeql-action/upload-sarif@v4.37.4
with:
sarif_file: trivy-results.sarif
category: trivy-image

View File

@@ -55,75 +55,9 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "✓ Valid version: $VERSION"
web-build:
name: Build shared Next standalone
needs: validate
# Stage 8 (issue #10321): the four desktop legs used to each run the full
# `npm run build` (Next standalone) — ~111 runner-minutes per release just to
# produce the same platform-independent bundle four times. This job builds it
# once on ubuntu; every leg then restores the byte-verified archive and
# re-forks its native optionals (scripts/build/standaloneBundle.mjs).
#
# Rollback lever: set the repo variable ELECTRON_SHARED_STANDALONE=disabled.
# This job then skips, every leg falls back to building its own web bundle
# (the legacy step below), and the pipeline behaves exactly like pre-Stage 8 —
# no revert needed.
if: ${{ !cancelled() && needs.validate.result == 'success' && vars.ELECTRON_SHARED_STANDALONE != 'disabled' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Install dependencies
run: npm ci
env:
NPM_CONFIG_LEGACY_PEER_DEPS: true
- name: Build Next.js standalone
# webpack, not Turbopack, for the same hosted-runner RAM reason as the
# linux leg (see the long comment on the fallback step in `build`).
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144"
OMNIROUTE_USE_TURBOPACK: "0"
run: npm run build
- name: Pack standalone bundle
# Deterministic tar.gz + byte-level manifest; the manifest embeds the
# archive's own sha256 so artifact-transfer corruption is caught before
# extraction, and every entry is re-verified after extraction.
run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz
- name: Upload shared web bundle
uses: actions/upload-artifact@v7
with:
name: web-standalone-bundle
# compression-level 0: the payload is already a deterministic tar.gz;
# re-zipping would only burn runner CPU without shrinking it further.
compression-level: 0
# Legs consume this within minutes; no reason to retain it like the
# installer artifacts (default 90d).
retention-days: 3
path: |
web-bundle.tar.gz
web-bundle.tar.gz.manifest.json
build:
name: Build Electron (${{ matrix.platform }})
needs: [validate, web-build]
# `web-build` is skipped when ELECTRON_SHARED_STANDALONE=disabled (rollback
# mode); legs then run the legacy per-leg web build below. If it ran and
# failed, fail closed: legs cannot package without the bundle, and silently
# falling back to four per-leg builds would hide exactly the regression the
# shared job exists to surface.
if: ${{ !cancelled() && needs.validate.result == 'success' && (needs.web-build.result == 'success' || needs.web-build.result == 'skipped') }}
needs: validate
runs-on: ${{ matrix.runner }}
permissions:
contents: write # electron-builder may publish artifacts with GH_TOKEN
@@ -135,27 +69,19 @@ jobs:
runner: windows-latest
target: win
ext: .exe
os: win32
arch: x64
- platform: macos-intel
runner: macos-15-intel
target: mac-x64
ext: .dmg
os: darwin
arch: x64
- platform: macos-arm64
runner: macos-latest
target: mac-arm64
ext: -arm64.dmg
os: darwin
arch: arm64
- platform: linux
runner: ubuntu-latest
target: linux
ext: .AppImage
deb_ext: .deb
os: linux
arch: x64,arm64
steps:
- uses: actions/checkout@v7
@@ -167,6 +93,14 @@ jobs:
node-version: 24
cache: npm
- name: Cache node_modules
uses: actions/cache@v6.1.0
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
env:
@@ -182,11 +116,7 @@ jobs:
mkdir -p "$RUNNER_TEMP/home"
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
- name: Build Next.js standalone (legacy per-leg fallback)
# Stage 8: only runs in rollback mode (ELECTRON_SHARED_STANDALONE=disabled)
# or when the shared web-build job was skipped. Otherwise the leg restores
# the shared bundle from the `web-build` job below.
if: needs.web-build.result == 'skipped'
- name: Build Next.js standalone
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144"
@@ -204,30 +134,6 @@ jobs:
OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }}
run: npm run build
- name: Download shared web bundle
# Stage 8: inverse of the fallback step above — runs exactly when the
# shared `web-build` job produced the bundle.
if: needs.web-build.result == 'success'
uses: actions/download-artifact@v8
with:
name: web-standalone-bundle
- name: Restore + hydrate shared web bundle
if: needs.web-build.result == 'success'
shell: bash
# restore: verify the archive's sha256 against the manifest, extract, then
# re-verify every entry (existence + size + content hash + symlink
# targets, and no unlisted files) byte-for-byte.
# hydrate: the bundle was built on ubuntu, so install-machine-forked native
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
# fsevents) carry linux forks. Replace them with the forks this
# leg's own `npm ci` resolved, then assert every bundled native
# (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime)
# can service this leg's platform/arch before packaging starts.
run: |
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz
node scripts/build/standaloneBundle.mjs hydrate --platform ${{ matrix.os }} --arch ${{ matrix.arch }}
- name: Sync version in electron/package.json
shell: bash
env:
@@ -252,7 +158,7 @@ jobs:
- name: Install Electron dependencies
working-directory: electron
run: npm ci --no-audit --no-fund
run: npm install --no-audit --no-fund
- name: Build Electron for ${{ matrix.platform }}
working-directory: electron
@@ -279,14 +185,9 @@ jobs:
- name: Smoke packaged Electron app (Linux)
if: matrix.platform == 'linux'
# #7592: also cold-restart against the same DATA_DIR and assert a
# native SQLite driver (not the sql.js WASM fallback) is selected on
# the second launch — blocking here since Linux has no Windows-style
# sandbox caveats that would make it flaky.
env:
ELECTRON_SMOKE_TIMEOUT_MS: 60000
ELECTRON_SMOKE_STREAM_LOGS: "1"
ELECTRON_SMOKE_COLD_RESTART: "1"
run: xvfb-run -a npm run electron:smoke:packaged
- name: Collect installers

View File

@@ -137,7 +137,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
- run: npm run check:api-docs-refs
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
@@ -181,7 +181,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
@@ -430,7 +430,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
# which is where flaky-detection volume actually comes from (ci.yml's heavy
# jobs only run on the release PR). Advisory upload, own-origin only.
@@ -476,7 +476,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
# QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do
# comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes
# silenciosamente não rodavam no fast path) e o setupPolyfill não era importado.
@@ -516,7 +516,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
@@ -583,7 +583,7 @@ jobs:
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm ci
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
run: npm run check:changelog-integrity
- name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo)

View File

@@ -1,64 +0,0 @@
# Publica o export estável do catálogo consumido pelo OmniRoute Radar numa URL
# fixa (asset de release `radar-export-latest`), para o servidor privado do Radar
# (1 GB RAM, nunca clona/builda o OmniRoute) baixá-lo via `RADAR_EXPORT_URL` em
# vez de depender do snapshot gravado no deploy. Fonte: scripts/release/radar-export.mjs.
#
# A URL estável resultante (definir em RADAR_EXPORT_URL no .env do radar-server):
# https://github.com/diegosouzapw/OmniRoute/releases/download/radar-export-latest/export-omniroute.json
name: Radar Export
on:
workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref)
push:
branches: [main] # produção: só o catálogo do main clobra o asset estável
paths:
- open-sse/config/freeModelCatalog.data.ts
- open-sse/config/freeModelCatalog.ts
- open-sse/config/providerRegistry.ts
- open-sse/config/providers/**
- scripts/release/radar-export.mjs
- .github/workflows/radar-export.yml
schedule:
- cron: "17 6 * * 1" # semanal (segunda 06:17 UTC): mantém geradoEm/proveniência frescos
permissions:
contents: read
concurrency:
group: radar-export-${{ github.ref }}
cancel-in-progress: true
env:
CI_NODE_VERSION: "24"
jobs:
publish-export:
runs-on: ubuntu-latest
permissions:
contents: write # gh release upload — clobra o asset estável do export
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false # publish usa GH_TOKEN via gh release, não a credencial do checkout
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- name: Generate catalog export with provenance
run: node --import tsx/esm scripts/release/radar-export.mjs "$RUNNER_TEMP/export-omniroute.json"
- name: Publish to the stable release asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="radar-export-latest"
# Cria o release estável na primeira vez; nas seguintes só re-anexa o asset.
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "Radar catalog export (rolling)" \
--notes "Export estável do catálogo OmniRoute para o Radar. Atualizado automaticamente; NÃO é um release de versão do produto." \
--latest=false
fi
gh release upload "$TAG" "$RUNNER_TEMP/export-omniroute.json" --repo "$GITHUB_REPOSITORY" --clobber

7
.gitignore vendored
View File

@@ -1,7 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# project-specific directories
/output/
.slim/deepwork/
.omnivscodeagent/
omnirouteCloud/
@@ -70,8 +69,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# Local gitleaks artifacts (do not commit)
gitleaks-local.json
!.env.example
!.env.homolog.example
!.env.devin-bridge.example
@@ -288,7 +285,3 @@ docker-compose.yml.bak
# CLI local cache/state
.playwright-cli
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
/.sandbox/
.aider*

View File

@@ -76,17 +76,6 @@ import {
type FreeModelFreeType,
} from "./naming.js";
/**
* Minimal leveled logger sink accepted by the default fetchers and the static
* catalog builder. A full `Logger` satisfies it structurally; the config hook
* injects the same partial shape (see `createOmniRouteConfigHook` deps).
*/
type OmniRouteLoggerSink = {
error?: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
debug?: (message: string, ...args: unknown[]) => void;
};
/**
* Zod schema for plugin options accepted as the second element of the
* `plugin: [name, opts]` tuple in opencode.json. Strict by design — unknown
@@ -802,18 +791,13 @@ export async function forceSyncOmniRouteModels(args: {
try {
rawCombos = await combosFetcher(auth.baseURL, auth.managementReadToken, 10_000);
} catch (err) {
logger.warn("force sync: combos fetch failed", err);
console.warn("[omniroute-plugin] force sync: combos fetch failed", err);
}
}
let rawAutoCombos: OmniRouteRawAutoCombo[] = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(
auth.baseURL,
auth.managementReadToken,
5_000,
logger
);
rawAutoCombos = await autoCombosFetcher(auth.baseURL, auth.managementReadToken, 5_000);
} catch {
/* soft-fail */
}
@@ -1105,7 +1089,7 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
return {
auth: createOmniRouteAuthHook(resolved),
provider: createOmniRouteProviderHook(resolved, { cache: sharedCache, logger }),
provider: createOmniRouteProviderHook(resolved, { cache: sharedCache }),
config: configWithSyncCommand,
tool: {
omniroute_sync_models: syncTool,
@@ -1310,15 +1294,10 @@ export function mapRawModelToModelV2(
// `(providerID, modelID)`. If the raw id is already provider-prefixed
// (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or
// `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave
// it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids
// (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed:
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under
// the plugin provider (#10345). Other bare ids still prefix with
// `providerId` so credentials resolve as `(omniroute, model)`.
id:
raw.id.includes("/") || raw.owned_by === "combo"
? raw.id
: `${ctx.providerId}/${raw.id}`,
// it as-is — double-prefixing breaks OC's lookup. Otherwise prefix with
// the resolved `providerId` so a bare key like `claude-opus-4` parses as
// `(omniroute, claude-opus-4)` and the credentials resolve correctly.
id: raw.id.includes("/") ? raw.id : `${ctx.providerId}/${raw.id}`,
/**
* Display name. Falls back to raw.id when no enrichment is available;
* the caller (`createOmniRouteProviderHook`) overlays
@@ -1692,8 +1671,7 @@ export interface OmniRouteRawAutoCombo {
export type OmniRouteAutoCombosFetcher = (
baseURL: string,
apiKey: string,
timeoutMs?: number,
logger?: OmniRouteLoggerSink
timeoutMs?: number
) => Promise<OmniRouteRawAutoCombo[]>;
/**
@@ -1705,11 +1683,9 @@ export type OmniRouteAutoCombosFetcher = (
export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = async (
baseURL,
apiKey,
timeoutMs = 5_000,
logger?: OmniRouteLoggerSink
timeoutMs = 5_000
) => {
if (!apiKey || !baseURL) return [];
const log = logger ?? _logger;
const trimmed = trimTrailingSlashes(baseURL);
const root = trimmed.replace(/\/v\d+$/, "");
@@ -1728,11 +1704,15 @@ export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = asy
});
// 404 = endpoint not deployed yet — expected during rollout
if (res.status === 404) {
log.warn(`/api/combos/auto not available (404) — auto combos disabled`);
console.warn(
`[omniroute-plugin] /api/combos/auto not available (404) — auto combos disabled`
);
return [];
}
if (!res.ok) {
log.warn(`/api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled`);
console.warn(
`[omniroute-plugin] /api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled`
);
return [];
}
const body = (await res.json()) as unknown;
@@ -1750,8 +1730,8 @@ export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = asy
return out;
} catch (err) {
// Network error, timeout, abort — all non-fatal
log.warn(
`/api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled`
console.warn(
`[omniroute-plugin] /api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled`
);
return [];
} finally {
@@ -2950,7 +2930,10 @@ export function passesModelAllowlist(
* filter is set, all combos pass. Combos with zero resolvable members pass
* (mirrors `isUsableCombo` semantics).
*/
export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelListFilter): boolean {
export function passesComboAllowlist(
combo: OmniRouteRawCombo,
visible?: ModelListFilter
): boolean {
if (!visible) return true;
const steps = Array.isArray(combo.models) ? combo.models : [];
if (steps.length === 0) return true;
@@ -3142,15 +3125,9 @@ export function createOmniRouteProviderHook(
providersFetcher?: OmniRouteProvidersFetcher;
now?: () => number;
cache?: OmniRouteFetchCache;
logger?: _Logger;
} = {}
): ProviderHook {
const resolved = resolveOmniRoutePluginOptions(opts);
const logger =
deps.logger ??
createLogger(
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
);
const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher;
// T-05: combo discovery merges `/api/combos` entries into the same map as
// `/v1/models`. Default fetcher is declared further down the file; the
@@ -3224,8 +3201,8 @@ export function createOmniRouteProviderHook(
: undefined) ??
"";
if (!baseURL) {
logger.error(
`provider.models(${resolved.providerId}): ` +
console.warn(
`[omniroute-plugin] provider.models(${resolved.providerId}): ` +
`no baseURL resolvable — checked plugin opts, auth.json, and provider config. ` +
`Set baseURL in opencode.json plugin options or run \`opencode connect ${resolved.providerId}\` with a baseURL.`
);
@@ -3256,8 +3233,8 @@ export function createOmniRouteProviderHook(
rawModels = await fetcher(baseURL, apiKey, 10_000);
// T-05: combos fetch is best-effort, gated by features.combos.
// Soft-fail on any error: emit a warn-level diagnostic and fall back
// to a models-only catalog. Rationale: /api/combos requires a
// Soft-fail on any error: emit a console.warn and fall back to a
// models-only catalog. Rationale: /api/combos requires a
// management-scoped key and OmniRoute may not have any combos
// provisioned. Hard-failing when combos are optional would
// silently hide the whole provider from OC's picker.
@@ -3266,7 +3243,10 @@ export function createOmniRouteProviderHook(
try {
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn("combos fetch failed, falling back to models-only catalog", err);
console.warn(
"[omniroute-plugin] combos fetch failed, falling back to models-only catalog",
err
);
}
}
@@ -3276,7 +3256,7 @@ export function createOmniRouteProviderHook(
rawAutoCombos = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000, logger);
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
} catch {
// Already handled inside the default fetcher — this catch
// is belt-and-suspenders for injected stubs.
@@ -3290,7 +3270,10 @@ export function createOmniRouteProviderHook(
try {
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn("enrichment fetch failed, falling back to raw ids", err);
console.warn(
"[omniroute-plugin] enrichment fetch failed, falling back to raw ids",
err
);
}
}
@@ -3305,7 +3288,7 @@ export function createOmniRouteProviderHook(
10_000
);
} catch (err) {
logger.warn("compression-metadata fetch failed", err);
console.warn("[omniroute-plugin] compression-metadata fetch failed", err);
}
}
@@ -3319,8 +3302,8 @@ export function createOmniRouteProviderHook(
try {
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"/api/providers fetch failed; usableOnly filter disabled for this refresh",
console.warn(
"[omniroute-plugin] /api/providers fetch failed; usableOnly filter disabled for this refresh",
err
);
}
@@ -3339,9 +3322,8 @@ export function createOmniRouteProviderHook(
// Debug breadcrumb: surface fetch result so operators can confirm
// the dynamic pipeline fired and how much catalog OmniRoute returned.
// Emitted once per cache miss (TTL refresh) — quiet on cache hits.
// Info-level: hidden at the default `warn` level (see #8982).
logger.info(
`catalog refreshed for providerId=${resolved.providerId} baseURL=${baseURL}: ` +
console.warn(
`[omniroute-plugin] catalog refreshed for providerId=${resolved.providerId} baseURL=${baseURL}: ` +
`${rawModels.length} models + ${rawCombos.length} combos + ` +
`${rawEnrichment.size} enrichment entries + ` +
`${rawCompressionCombos.length} compression combos + ` +
@@ -3621,7 +3603,9 @@ export function createOmniRouteProviderHook(
const dedupeKey = `${cacheKey}::${comboKey}`;
if (!collisionWarned.has(dedupeKey)) {
collisionWarned.add(dedupeKey);
logger.warn(`combo key "${comboKey}" collides with a model id; combo wins.`);
console.warn(
`[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.`
);
}
}
}
@@ -3639,8 +3623,8 @@ export function createOmniRouteProviderHook(
}
if (pending.length > 0) {
logger.warn(
`${pending.length} combo(s) could not resolve all nested combo-refs after ${MAX_COMBO_PASSES} passes; they will advertise context=0 to avoid over-claiming.`
console.warn(
`[omniroute-plugin] ${pending.length} combo(s) could not resolve all nested combo-refs after ${MAX_COMBO_PASSES} passes; they will advertise context=0 to avoid over-claiming.`
);
}
@@ -4284,10 +4268,8 @@ export function buildStaticProviderEntry(
enrichment?: OmniRouteEnrichmentMap,
compressionCombos?: OmniRouteCompressionCombo[],
connections?: OmniRouteProviderConnection[],
rawAutoCombos?: OmniRouteRawAutoCombo[],
logger?: OmniRouteLoggerSink
rawAutoCombos?: OmniRouteRawAutoCombo[]
): OmniRouteStaticProviderEntry {
const log = logger ?? _logger;
const models: Record<string, OmniRouteStaticModelEntry> = {};
const rawModelKeys = new Set<string>();
@@ -4665,8 +4647,8 @@ export function buildStaticProviderEntry(
}
if (pendingStatic.length > 0) {
log.warn(
`${pendingStatic.length} combo(s) in the static catalog could not resolve all nested combo-refs after ${MAX_STATIC_COMBO_PASSES} passes; they will be omitted.`
console.warn(
`[omniroute-plugin] ${pendingStatic.length} combo(s) in the static catalog could not resolve all nested combo-refs after ${MAX_STATIC_COMBO_PASSES} passes; they will be omitted.`
);
}
@@ -4687,7 +4669,9 @@ export function buildStaticProviderEntry(
const isExpectedRawTwin = autoCombo.id === key && rawModelKeys.has(key);
if (!isExpectedRawTwin && !reportedCollisions.has(key)) {
reportedCollisions.add(key);
log.warn(`auto combo key "${key}" collides with an existing model; auto combo wins.`);
console.warn(
`[omniroute-plugin] auto combo key "${key}" collides with an existing model; auto combo wins.`
);
}
}
models[key] = entry;
@@ -5358,8 +5342,7 @@ export function createOmniRouteConfigHook(
warmSnapshot = snapshotResult;
// Log snapshot age (accept any age — instant beats empty).
const age = (snapshotResult as { writtenAt?: number }).writtenAt;
const ageLabel =
typeof age === "number" ? `${Math.round((Date.now() - age) / 3_600_000)}h` : "unknown";
const ageLabel = typeof age === "number" ? `${Math.round((Date.now() - age) / 3_600_000)}h` : "unknown";
logAt(
"warn",
`config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
@@ -5411,12 +5394,7 @@ export function createOmniRouteConfigHook(
const doAutoCombos = async (): Promise<void> => {
if (!wantAutoCombos) return;
try {
localRawAutoCombos = await autoCombosFetcher(
baseURL,
managementReadToken,
5_000,
logger
);
localRawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
} catch {
// Already handled inside the default fetcher
}
@@ -5437,11 +5415,7 @@ export function createOmniRouteConfigHook(
const doCompression = async (): Promise<void> => {
if (!wantCompressionMeta) return;
try {
localRawCompressionCombos = await compressionMetaFetcher(
baseURL,
managementReadToken,
10_000
);
localRawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logAt(
"error",
@@ -5554,8 +5528,7 @@ export function createOmniRouteConfigHook(
localRawEnrichment,
localRawCompressionCombos,
localRawConnections,
localRawAutoCombos,
logger
localRawAutoCombos
);
const inputWithProvider2 = input as { provider?: Record<string, unknown> };
if (inputWithProvider2.provider) {
@@ -5645,8 +5618,7 @@ export function createOmniRouteConfigHook(
rawEnrichment,
rawCompressionCombos,
rawConnections,
rawAutoCombos,
logger
rawAutoCombos
);
// Mutate the input.provider map. The Config type declares

View File

@@ -1,34 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mapRawModelToModelV2 } from "../src/index.ts";
test("mapRawModelToModelV2: bare combo ids stay unprefixed (#10345)", () => {
const combo = mapRawModelToModelV2(
{
id: "gpt-5.6-sol",
owned_by: "combo",
context_length: 272000,
max_output_tokens: 8192,
},
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
);
assert.equal(combo.id, "gpt-5.6-sol");
assert.equal(combo.providerID, "omniroute");
const slashed = mapRawModelToModelV2(
{
id: "cx/gpt-5.6-sol",
owned_by: "combo",
context_length: 272000,
},
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
);
assert.equal(slashed.id, "cx/gpt-5.6-sol");
const ordinary = mapRawModelToModelV2(
{ id: "claude-primary", context_length: 200000 },
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
);
assert.equal(ordinary.id, "omniroute/claude-primary");
});

View File

@@ -5,14 +5,8 @@ import { join } from "node:path";
import test from "node:test";
import type { Config } from "@opencode-ai/plugin";
import {
createOmniRouteConfigHook,
createOmniRouteProviderHook,
defaultOmniRouteAutoCombosFetcher,
OmniRoutePlugin,
type OmniRouteRawModelEntry,
} from "../src/index.js";
import { createLogger, getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
import { createOmniRouteConfigHook, OmniRoutePlugin } from "../src/index.js";
import { getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
type ConsoleMethod = "error" | "info" | "log" | "warn";
type ConsoleEntries = Record<ConsoleMethod, unknown[][]>;
@@ -222,105 +216,3 @@ test("logger error output remains visible at error level", async () => {
setLogLevel(previousLevel);
}
});
const MINIMAL_MODELS: OmniRouteRawModelEntry[] = [
{
id: "claude-primary",
object: "model",
owned_by: "combo",
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true },
context_length: 200000,
max_output_tokens: 64000,
input_modalities: ["text", "image"],
output_modalities: ["text"],
},
];
function providerHookWithLevel(level: LogLevel, baseURL?: string) {
return createOmniRouteProviderHook(
{
baseURL,
features: { autoCombos: false, enrichment: false, logLevel: level },
},
{
fetcher: async () => MINIMAL_MODELS,
combosFetcher: async () => {
throw new Error("combos boom");
},
}
);
}
test("logLevel error suppresses provider.models() fallback warnings and the catalog-refresh breadcrumb", async () => {
const hook = providerHookWithLevel("error", "https://or.example.com/v1");
const lines = rendered(
await captureConsole(async () => {
await hook.models!({} as never, { auth: { type: "api", key: "sk-x" } as never });
})
);
assert.equal(lines.filter((line) => line.includes("combos fetch failed")).length, 0);
assert.equal(lines.filter((line) => line.includes("catalog refreshed")).length, 0);
});
test("logLevel debug preserves the provider.models() catalog-refresh breadcrumb", async () => {
const hook = providerHookWithLevel("debug", "https://or.example.com/v1");
const lines = rendered(
await captureConsole(async () => {
await hook.models!({} as never, { auth: { type: "api", key: "sk-x" } as never });
})
);
assert.ok(
lines.some((line) => line.includes("catalog refreshed")),
"catalog-refresh breadcrumb emitted at debug level"
);
});
test("no baseURL resolvable stays visible at error level", async () => {
const hook = providerHookWithLevel("error");
const lines = rendered(
await captureConsole(async () => {
await hook.models!({} as never, { auth: { type: "api", key: "sk-x" } as never });
})
);
assert.ok(
lines.some((line) => line.includes("no baseURL resolvable")),
"genuine misconfiguration error remains visible at error level"
);
});
test("default auto-combos fetcher 404 warning respects the threaded logger level", async () => {
const originalFetch = globalThis.fetch;
(globalThis as { fetch: unknown }).fetch = (async () => ({
status: 404,
ok: false,
})) as typeof fetch;
try {
const silent = await captureConsole(async () => {
await defaultOmniRouteAutoCombosFetcher(
"https://or.example.com/v1",
"sk-x",
5_000,
createLogger("error")
);
});
assert.equal(rendered(silent).length, 0, "404 warning suppressed at error level");
const loud = await captureConsole(async () => {
await defaultOmniRouteAutoCombosFetcher(
"https://or.example.com/v1",
"sk-x",
5_000,
createLogger("warn")
);
});
assert.ok(
rendered(loud).some((line) => line.includes("/api/combos/auto not available")),
"404 warning emitted at warn level"
);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 350 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 340 LLM providers, auto-fallback.
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -56,9 +56,9 @@ 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 (159 migrations) |
| Database | `src/lib/db/` | SQLite domain modules (148 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 110 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
| 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 |
| Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory |
@@ -433,7 +433,6 @@ 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

@@ -2,18 +2,6 @@
## [Unreleased]
### ✨ New Features
- **feat(sse): STRICT_ZERO_COST** — opt-in, off-by-default `freeAccessPolicy: "strict"` setting
that hard-verifies every auto-combo candidate against live quota state and per-connection
economic safety before it can be dispatched, going beyond `hidePaidModels`'s static catalog
check. Adds curated `hardStopGuaranteed` metadata to `FREE_MODEL_BUDGETS`, a short-TTL quota
cache reusing `getUsageForProvider()`, and a connection-safety guarantee: a candidate backed
by multiple accounts has its `allowedConnectionIds` narrowed to exactly the connections
independently verified `SAFE`, so dispatch can never use an unverified account. An
`excludeTosAvoid` guard (default `false`) is available separately for contractual risk. See
`docs/routing/STRICT_ZERO_COST.md`.
---
## [3.8.50] — TBD
@@ -21,7 +9,6 @@
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._
### ✨ New Features
- **feat(search):** first-class X Search provider (`x-search`) on `POST /v1/search` and MCP `omniroute_x_search` using SuperGrok / xAI server-side `x_search`. Explicit provider or `search_type: "x"` only — never auto-selected for web. Reuses `xai-oauth` / `xao` / `xai` credentials. Not the X Developer Platform MCP. ([#10985](https://github.com/diegosouzapw/OmniRoute/issues/10985))
- **feat(core):** add Layer A capability filter at router (#5696)
- **feat(providers):** add DeepAI as paid API-key image provider ([#6671](https://github.com/diegosouzapw/OmniRoute/issues/6671))
- **feat(providers):** add Naga.ac and ChatAnywhere aggregator gateway providers (#6674 — thanks @chirag127)
@@ -180,9 +167,6 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
### 🐛 Bug Fixes
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
- **cli**: route provider test commands through configured connection test endpoints (#10570)
- **executors**: fix internal timeout misclassified as client disconnect (499) for 7 niche executors — pass TimeoutError reason to controller.abort() (#8197 side-finding)
- test(combo): guard auto/best-free never leaks the combo name as a model (#7754)
- fix(vision-bridge): describe-model no longer returns unreachable "openai/gpt-4o-mini" when every vision-capable provider is unreachable on the instance — returns null instead and surfaces a clear error (#8430)

View File

@@ -47,21 +47,6 @@ rewrite it to the `_tasks/…` equivalent before writing:
Commit those artifacts inside the `_tasks/` repo (`git -C _tasks …`), never in the main repo.
## Scratch / temporary files — use `_artifacts/`, not `/tmp`
This project overrides the harness's default session scratchpad (`/tmp/claude-*/…`). Write
temporary/working files — exports, generated zips, one-off intermediate outputs, anything you'd
otherwise put in `/tmp` — to `/home/diegosouzapw/dev/proxys/OmniRoute/_artifacts/` instead.
- `_artifacts/` is a root `_*` path: already gitignored (`AGENTS.md` → "Root `_*` paths"), lives
on disk only, never tracked.
- Reason: keeping scratch output inside the project (vs `/tmp`) makes it trivial for the operator
to find and delete everything temporary in one place, instead of hunting across ephemeral
session-specific `/tmp` directories that vanish or accumulate untracked.
- Do **not** confuse this with `_tasks/` (Hard Rule #23, its own private git repo for durable
plans/specs/research/hand-offs) — `_artifacts/` is for disposable working files only, nothing
here needs to survive or be versioned.
## Base-green before opening PRs
Before cutting a branch or opening a PR, run the base-green check (`AGENTS.md` → Git Workflow →

View File

@@ -8,8 +8,8 @@ WORKDIR /app
# that already have a fix published in trixie. CVEs without an upstream fix yet
# (local-only TOCTOU, etc.) remain until the distro patches them and the image
# is rebuilt; none are reachable from the proxy's request surface at runtime.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
@@ -61,8 +61,8 @@ FROM base AS builder
# Build tools for native module compilation
# apt-get update needed here because base's rm -rf clears the shared cache
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
@@ -108,7 +108,7 @@ RUN test -f package-lock.json \
# in production (TlsClientUnavailableError, #7802). Run it explicitly here so
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
# broken image.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
&& (cd node_modules/better-sqlite3 \
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
@@ -140,18 +140,6 @@ ENV OMNIROUTE_USE_TURBOPACK="${OMNIROUTE_USE_TURBOPACK}"
ARG OMNIROUTE_BASE_PATH=""
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
# #10273: the dashboard's `frame-ancestors` policy is compiled into the route
# manifest by next.config.mjs (via scripts/build/dashboardEmbed.mjs), so it is
# fixed when the image is built and cannot be flipped with `-e` on a running
# container. Build with `--build-arg DASHBOARD_ALLOW_EMBED=vscode` to produce an
# image whose HTML pages may be framed by the VS Code Simple Browser
# (OmniCopilot's `dashboardOpen: "editor"`). Unset — the default — keeps every
# route on `frame-ancestors 'none'` + X-Frame-Options: DENY. Builder-stage only:
# the runner stage deliberately does not carry it, because a runtime value would
# suggest an effect it cannot have.
ARG DASHBOARD_ALLOW_EMBED=""
ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
# Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert
# access), so keep @/mitm/manager on the graceful stub (#3390). This flag is
# Docker-only: npm/Electron/VPS builds must bundle the REAL manager (#6344).
@@ -170,10 +158,10 @@ ARG OMNIROUTE_BUILD_MEMORY_MB=4096
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
COPY . ./
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \
mkdir -p /app/data \
&& npm run build \
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
# ── Runner base ────────────────────────────────────────────────────────────
FROM base AS runner-base
@@ -274,8 +262,8 @@ COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
# browsers land under /home/node which persists across image layers and is
# accessible to the non-root runtime user.
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& node node_modules/playwright/cli.js install chromium --with-deps \
&& chown -R node:node /home/node/.cache \
@@ -296,15 +284,15 @@ COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-
COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
# Install system dependencies required by openclaw (git+ssh references).
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \
&& rm -rf /var/lib/apt/lists/* \
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
# Install CLI tools globally. Separate layer from apt for better cache reuse.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
USER node

View File

@@ -1,146 +0,0 @@
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
FROM oven/bun:1.3.14-slim AS base
WORKDIR /app
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
build-essential \
python3 \
python-is-python3 \
make \
g++ \
libsecret-1-0 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# ── Builder stage (100% Bun Native Install & Build) ─────────────────────────
FROM base AS builder
WORKDIR /app
COPY . .
# Fast Bun native package install
RUN bun install --include=optional --quiet
# Compile native better-sqlite3 Node-API addon under Bun
RUN if [ -d "node_modules/better-sqlite3" ]; then \
(cd node_modules/better-sqlite3 && bunx node-gyp rebuild); \
fi
# Fetch tls-client-node native binary if script exists
RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ]; then \
bun node_modules/tls-client-node/scripts/postinstall.js || true; \
fi
# Disable Turbopack for Bun builder stage (Turbopack V8 internal worker bindings require Node)
ENV OMNIROUTE_USE_TURBOPACK=0
ARG OMNIROUTE_BASE_PATH=""
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
ARG DASHBOARD_ALLOW_EMBED=""
ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Bun native Next.js build execution
RUN bun run --quiet build
# ── Runner Base stage (100% Bun Native Production Runtime) ──────────────────
FROM oven/bun:1.3.14-slim AS runner-base
LABEL org.opencontainers.image.title="omniroute" \
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \
org.opencontainers.image.url="https://omniroute.online" \
org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \
org.opencontainers.image.licenses="MIT"
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libsecret-1-0 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
ENV PORT=20128
ENV HOSTNAME=0.0.0.0
ENV OMNIROUTE_MEMORY_MB=1024
ENV DATA_DIR=/app/data
RUN mkdir -p /app/data
COPY --from=builder /app/.build/next/standalone ./
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
EXPOSE 20128
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD bun healthcheck.mjs || exit 1
ENTRYPOINT ["bun", "dev/run-standalone.mjs"]
# ── Runner Web stage (Bun Native + Chromium/Playwright for Web providers) ───
FROM runner-base AS runner-web
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
chromium \
chromium-driver \
fonts-liberation \
libasound2t64 \
gconf-service \
libatk-bridge2.0-0 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libexpat1 \
libfontconfig1 \
libgbm1 \
libgcc-s1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
pangocairo-1.0-0 \
stdc++6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
ca-certificates \
fonts-gargi \
fonts-ipafont-gothic \
fonts-kacst \
fonts-thai-tlwg \
fonts-wqy-zenhei \
&& rm -rf /var/lib/apt/lists/*
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
# Return to the base image non-root user after the apt install (mirrors the
# Node Dockerfile runner-web stage, which re-asserts USER node).
USER bun

View File

@@ -1,447 +0,0 @@
---
title: "Provider Reference"
version: 3.8.50
lastUpdated: 2026-08-21
---
# Provider Reference
> **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand.
> Regenerate with: `npm run gen:provider-reference`
> **Last generated:** 2026-08-21
Total providers: **349**. See category breakdown below.
## Categories
- **Free** — free tier with API key (configured via dashboard)
- **No-auth** — public endpoints that require no key or sign-in at all
- **OAuth** — sign-in flow handled by OmniRoute, no API key needed
- **Web cookie** — wraps the provider's web app via cookie auth
- **API key** — paid provider configured via API key (free credits may apply)
- **Local** — runs on the user's machine (Ollama, LM Studio, vLLM, etc.)
- **Search** — web search providers
- **Audio** — audio-only providers (TTS/STT)
- **Upstream proxy** — providers that proxy to other providers
- **Cloud agent** — long-running coding agents (Codex Cloud, Devin, Jules)
- **System** — OmniRoute-internal providers (loopback, etc.)
Additional tags: `image`, `video`, `aggregator`, `enterprise`, `embed/rerank`, `self-hosted`.
`Tool calling` (where shown): `native` — real function-calling API; `emulated` — the `tools` array is prompt-emulated via `webTools.ts` (regex-parsed `<tool>{...}</tool>` blocks); `none``tools` is currently silently dropped. See #7286.
Use the dashboard at `/dashboard/providers` to enable, configure, and test each provider.
---
## No-auth Providers (no key required) (11)
| ID | Alias | Name | Tags | Website | Notes | Tool calling |
|----|-------|------|------|---------|-------|--------------|
| `aihorde` | `horde` | AI Horde | No-auth | [link](https://aihorde.net) | No API key required — uses AI Horde's documented anonymous key. Adding a free aihorde.net key is optional and only buys higher queue priority (kudos). | — |
| `auggie` | `aug` | Augment (Auggie CLI) | No-auth | [link](https://augmentcode.com) | No API key stored by OmniRoute. Install the Auggie CLI and run `auggie login` on this machine, then OmniRoute spawns it locally for each request. | — |
| `chipotle` | `pepper` | Chipotle Pepper AI (Free) | No-auth | [link](https://amelia.chipotle.com) | No credentials required. Uses Chipotle's public support chatbot via reverse-engineered SockJS/STOMP protocol. | — |
| `cloudflare-playground` | `cfp` | Cloudflare AI Playground | No-auth | [link](https://playground.ai.cloudflare.com) | No credentials required — anonymous browser sessions over a reverse-engineered cf_agent WebSocket protocol (Playwright transport). | — |
| `devin-cli-agentic` | `dva` | Devin CLI Agentic Bridge | No-auth | [link](https://docs.devin.ai/work-with-devin/devin-cli) | Authentication is owned by the official Devin CLI in its isolated bridge volume. | emulated |
| `duckduckgo-web` | `ddgw` | DuckDuckGo AI Chat | No-auth | [link](https://duckduckgo.com/duckchat) | No credentials required — DuckDuckGo AI Chat is anonymous and free. | emulated |
| `felo-web` | `felo` | Felo | No-auth | [link](https://felo.ai) | No credentials required — Felo is a free, no-signup chat/search aggregator. | — |
| `opencode` | `oc` | OpenCode Free | No-auth | [link](https://opencode.ai) | No API key required — uses OpenCode's public free endpoint. | — |
| `theoldllm` | `tllm` | The Old LLM (Free) | No-auth | [link](https://theoldllm.vercel.app) | No credentials required. The executor auto-generates access tokens via an embedded Playwright browser instance. | — |
| `veoaifree-web` | `veo-free` | Veo AI Free | No-auth, video | [link](https://veoaifree.com) | No auth required. Rate limited to 6 requests/hour per IP. | — |
| `zcode` | `zc` | ZCode (GLM Coding Plan) | No-auth | [link](https://zcode.z.ai) | No API key stored by OmniRoute. The local ZCode app-server uses the existing builtin:zai-coding-plan login. | — |
## OAuth Providers (25)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
| `agy` | `agy` | Antigravity CLI | OAuth | [link](https://antigravity.google) | Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models). |
| `amazon-q` | `aq` | Amazon Q | OAuth | [link](https://aws.amazon.com/q/developer/) | Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate. |
| `antigravity` | — | Antigravity | OAuth | — | — |
| `claude` | `cc` | Claude Code | OAuth | — | — |
| `cline` | `cl` | Cline | OAuth | — | — |
| `clinepass` | `cp` | ClinePass | OAuth | [link](https://cline.bot/cline-pass) | ClinePass is Cline's $9.99/mo subscription bundling 10 open coding models. Sign in with your Cline account (same login as the Cline CLI/IDE), or paste a direct ClinePass API key (app.cline.bot → Settings → API Keys). A ClinePass subscription unlocks the cline-pass/* models. Reuses the Cline WorkOS OAuth flow. |
| `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. |
| `codex` | `cx` | OpenAI Codex | OAuth | — | — |
| `cursor` | `cu` | Cursor IDE | OAuth | — | — |
| `devin-cli` | `dv` | Devin CLI | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai |
| `devin-desktop` | — | Devin Desktop | OAuth | [link](https://devin.ai) | Paste an existing Devin API key from an authenticated Devin session. Key export availability and steps vary by Devin version and account. |
| `ghe-copilot` | `ghe-copilot` | GitHub Enterprise Copilot | OAuth | — | Enter your GHE instance URL (e.g., https://ghe.company.com) in provider settings, then authenticate via device flow. |
| `github` | `gh` | GitHub Copilot | OAuth | — | — |
| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | GitLab Duo OAuth is not configured. Register an OAuth application at https://gitlab.com/-/profile/applications with redirect URI http://localhost:20128/callback and scopes "ai_features read_user", then set GITLAB_DUO_OAUTH_CLIENT_ID (and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET) and restart. |
| `grok-cli` | `gc` | Grok Build | OAuth | — | Sign in with your browser, or paste your ~/.grok/auth.json (or the JWT access token) from the Grok Build CLI; refresh_token is rotated automatically either way. |
| `kilocode` | `kc` | Kilo Code | OAuth | — | — |
| `kimi-coding` | `kmc` | Kimi Code CLI | OAuth | [link](https://www.kimi.com/code?aff=omniroute) | Sign in with the same Kimi account used by Kimi Code CLI. OmniRoute uses the CLI OAuth flow and Kimi Coding Plan endpoints. |
| `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. |
| `openference` | `of` | Openference | OAuth | [link](https://openference.com) | Sign in with your Openference account to route requests through api.openference.com. An active plan is required for inference — OAuth may authenticate but return 402 without one. |
| `qoder` | `if` | Qoder | OAuth | — | — |
| `raycast` | `rc` | Raycast Pro AI | OAuth | [link](https://raycast.com/ai) | Unofficial integration — uses your Raycast Pro subscription via credentials from the macOS app (Auto-Import or manual capture). May break on Raycast updates. Not for redistribution; personal use only. |
| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT <token>', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry. |
| `xai-oauth` | `xao` | xAI OAuth (Grok) | OAuth | [link](https://x.ai) | Sign in with xAI to use api.x.ai models such as Grok 4.5. This is separate from Grok Build JWT sessions, which use cli-chat-proxy.grok.com and grok-build model aliases. |
| `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. |
| `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. |
## Web Cookie Providers (35)
| ID | Alias | Name | Tags | Website | Notes | Tool calling |
|----|-------|------|------|---------|-------|--------------|
| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | emulated |
| `adobe-firefly` | `firefly` | Adobe Firefly (Image/Video) | Web cookie | [link](https://firefly.adobe.com) | RECOMMENDED: firefly.adobe.com signed-in → F12 → Network → click firefly-3p.ff.adobe.io (generate-async or models/discovery) → Request Headers → Authorization → copy the token AFTER 'Bearer ' (starts with eyJ…). Cookie-only from firefly.adobe.com mints a GUEST token → 401/403; only multi-domain IMS cookies (adobelogin.com) or that Bearer JWT work. Unofficial/experimental media + Limits. | — |
| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | emulated |
| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | emulated |
| `chatgpt-web-codex` | `cgpt-codex` | ChatGPT Web (Codex) | Web cookie | [link](https://chatgpt.com) | Paste the full ChatGPT Cookie header. OmniRoute verifies it in an isolated headless browser profile. | native |
| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | none |
| `conol-web` | `cnl` | Conol (Unofficial/Experimental) | Web cookie | [link](https://conol.ai) | Use browser sign-in, or paste the full Cookie header from conol.ai. The __Secure-better-auth.session_token cookie is required. | — |
| `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/<path>?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. Optional: store a refresh_token in providerSpecificData.refreshToken (any Microsoft device-code/refresh flow for the substrate.office.com/sydney scopes) and OmniRoute pre-flight-refreshes the access token itself — otherwise re-capture after every ~75 min expiry. | — |
| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste the access_token from an authenticated copilot.microsoft.com request (DevTools → Network → Authorization), or export a HAR while logged in | — |
| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | emulated |
| `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | — |
| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | — |
| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. | emulated |
| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | — |
| `hailuo-web` | `hailuo-web` | Hailuo Web (MiniMax) | Web cookie | [link](https://hailuo.ai) | Open hailuo.ai, log in, then open DevTools → Application → Local Storage → copy the "_token" value. device_id/uuid fingerprint fields are derived automatically; if requests fail, re-capture _token (sessions can expire). | — |
| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste the full Cookie header from huggingface.co/chat (DevTools → Network → /chat/conversation → Request Headers → Cookie). It should include hf-chat and may also include token / aws-waf-token. | — |
| `hyperagent` | `ha` | HyperAgent (Unofficial/Experimental) | Web cookie | [link](https://hyperagent.com) | Paste the full Cookie header from hyperagent.com (DevTools → Network → any request → Request Headers → Cookie). Session cookies power chat + billing usage. | — |
| `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | emulated |
| `kimi-web` | `kimi-web` | Kimi Web | Web cookie | [link](https://www.kimi.com/code?aff=omniroute) | Paste access_token from www.kimi.com DevTools → Application → Local Storage. A legacy kimi-auth cookie is also accepted. | — |
| `lmarena` | `lma` | Arena (Free) | Web cookie | [link](https://arena.ai) | Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session. | — |
| `microsoft-designer-web` | `msdesigner` | Microsoft Designer (Image Generation) | Web cookie | [link](https://designer.microsoft.com) | Sign in at designer.microsoft.com, then open DevTools → Network, generate an image, and find the request to DallE.ashx?action=GetDallEImagesCogSci. Copy the value of its Authorization: Bearer header (the access_token — no 'Bearer ' prefix). The token is short-lived; this is an unofficial, reverse-engineered integration. | — |
| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess cookie AND the ecto1:... WS auth token from meta.ai. Capture the ecto1: token in DevTools → Network → WS → the clippy request's Authorization query param. Example: ecto_1_sess=4240a308...NVDg0; ecto1:ABCD... | emulated |
| `notion-web` | `nw` | Notion AI Web (Unofficial/Experimental) | Web cookie | [link](https://www.notion.so) | Paste only the token_v2 cookie VALUE from app.notion.com (DevTools → Application → Cookies → token_v2). Do not paste token_v2= or the full Cookie header. Workspace is auto-detected; space_id / notion_user_id are optional. | — |
| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai | emulated |
| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | — |
| `promptql` | `pql` | PromptQL (Unofficial/Experimental) | Web cookie | [link](https://prompt.ql.app) | Paste the Bearer JWT from prompt.ql.app DevTools → Network → graphql → Authorization (token only). Optional projectId + session Cookie for refresh. | — |
| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | emulated |
| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. Paste both values here. See provider setup docs for a step-by-step guide. | emulated |
| `tencent-aistudio-web` | `tasw` | Tencent AI Studio (Free) | Web cookie | [link](https://aistudio.tencent.ai) | Log in to aistudio.tencent.ai, open DevTools -> Network, copy any request Cookie header containing session tokens. | — |
| `tinycms-web` | `tcw` | TinyCMS Web (Free/Sub) | Web cookie | [link](https://site.tinycms.xyz) | Go to site.tinycms.xyz, open DevTools → Application → Local Storage, copy the value of 'app-config-uuid' (starts with 'R'), and paste it here. | — |
| `v0-vercel-web` | `v0-vercel-web` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | — |
| `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | — |
| `yuanbao-web` | `ybw` | Tencent Yuanbao (Free) | Web cookie | [link](https://yuanbao.tencent.com) | Log in to yuanbao.tencent.com, then paste the full Cookie header (DevTools → Network → any /api request → Request Headers → Cookie). It must contain hy_user and hy_token. | — |
| `zai-web` | `zw` | Z.ai Web | Web cookie | [link](https://chat.z.ai) | Copy the "token" value from chat.z.ai → DevTools → Application → Local Storage. Do not copy cookies; OmniRoute handles the per-request CAPTCHA through its browser transport. | — |
| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — |
## API Key Providers (paid / paid-with-free-credits) (233)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
| `360ai` | `360ai` | 360 AI | API key | [link](https://ai.360.cn) | Get API key at ai.360.cn |
| `agentrouter` | `agentrouter` | AgentRouter | API key, aggregator | [link](https://agentrouter.org) | $200 free credits on signup - multi-model routing gateway |
| `agnes` | `agnes` | Agnes AI | API key, video | [link](https://agnes-ai.com) | Get API key at agnes-ai.com |
| `ai21` | `ai21` | AI21 Labs | API key | [link](https://www.ai21.com) | $10 trial credits on signup (valid 3 months), no credit card required |
| `aimlapi` | `aiml` | AI/ML API | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. |
| `ainative` | `ainative` | AINative Studio | API key | [link](https://ainative.studio) | Create a free API key at ainative.studio (no card), then paste it here as a Bearer token. |
| `aion` | `aion` | Aion Labs | API key | [link](https://www.aionlabs.ai) | Create a free API key at aionlabs.ai (no card), then paste it here as a Bearer token. |
| `alibaba` | `ali` | Alibaba Cloud Model Studio | API key | [link](https://bailian.console.alibabacloud.com/) | — |
| `alibaba-cn` | `ali-cn` | Alibaba (China) | API key | [link](https://dashscope.console.aliyun.com/) | — |
| `ant-ling` | `ling` | Ant Ling / Ring (inclusionAI) | API key | [link](https://developer.ant-ling.com/en/docs/) | Register and create an API key at the Ant Ling API console (https://chat.ant-ling.com/open), then paste it here. OmniRoute routes chat traffic to https://api.ant-ling.com/v1/chat/completions; the provider is OpenAI-compatible and also exposes an Anthropic-compatible surface. |
| `anthropic` | `anthropic` | Anthropic | API key | [link](https://platform.claude.com) | — |
| `anyapi` | `anyapi` | AnyAPI AI | API key, aggregator | [link](https://anyapi.ai) | Free plan: 100,000 ANY Tokens/day and 100 RPM for eligible Free/Basic models; no credit card required. |
| `api-airforce` | `af` | Api.airforce | API key | [link](https://api.airforce) | 55 free tier models including Grok-3, Claude 3.7, Qwen3, Kimi-K2, Gemini 2.5 Flash, DeepSeek-V3 |
| `arcee-ai` | `arcee` | Arcee AI | API key | [link](https://arcee.ai) | Get API key at arcee.ai |
| `auriko` | `auriko` | Auriko | API key, aggregator | [link](https://www.auriko.ai) | Free plan publishes 1,000 Platform RPM and 10,000 BYOK RPM. Platform inference still passes through provider cost; this is not a free-token pool or unlimited free inference. |
| `azure-ai` | `azure-ai` | Azure AI Foundry | API key, enterprise | [link](https://learn.microsoft.com/azure/ai-foundry) | Use your Azure AI Foundry key. Base URL can be https://<resource>.services.ai.azure.com/openai/v1/ or https://<resource>.openai.azure.com/openai/v1/. |
| `azure-openai` | `azure` | Azure OpenAI | API key, enterprise | [link](https://azure.microsoft.com/products/ai-services/openai-service) | Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com. |
| `bai` | `bai` | b.ai | API key | [link](https://b.ai) | Bearer API key for the b.ai OpenAI-compatible LLM gateway (distinct from TheB.AI). Create a key at https://docs.b.ai, then use https://api.b.ai/v1 as the OpenAI-compatible base URL. |
| `baichuan` | `baichuan` | Baichuan | API key | [link](https://www.baichuan-ai.com/) | Get API key at platform.baichuan-ai.com |
| `baidu` | `baidu` | Baidu (ERNIE) | API key | [link](https://ernie.baidu.com/) | Get API key at console.bce.baidu.com |
| `bailian-coding-plan` | `bcp` | Alibaba Token Plan | API key | [link](https://www.alibabacloud.com/help/en/model-studio/token-plan-overview) | — |
| `baseten` | `baseten` | Baseten | API key | [link](https://baseten.co) | $30 free trial credits for GPU inference |
| `bazaarlink` | `bzl` | BazaarLink | API key | [link](https://bazaarlink.ai) | Use your BazaarLink API key (starts with sk-bl-) in Authorization: Bearer <key>. OpenAI SDK works with base URL https://bazaarlink.ai/api/v1. Models use provider/model-name format. |
| `bedrock` | `bedrock` | Amazon Bedrock | API key, enterprise | [link](https://aws.amazon.com/bedrock) | Use your Amazon Bedrock API key and configure the AWS region where your models are enabled (for example eu-west-2). OmniRoute calls Bedrock's native Converse API directly. |
| `black-forest-labs` | `bfl` | Black Forest Labs | API key, image | [link](https://blackforestlabs.ai) | — |
| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | Limited free access is available through Blackbox; model availability and account limits apply |
| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 |
| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — |
| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks |
| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. |
| `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup |
| `chat-oripe` | `chat-oripe` | Chat Oripe | API key, aggregator | [link](https://api.oriper.com) | Official metadata advertises 2M tokens/month, but the public site and documentation were blocked during audit; treat the quota and brand mapping as unconfirmed. |
| `chatanywhere` | `chatanywhere` | ChatAnywhere | API key, aggregator | [link](https://chatanywhere.tech) | Personal, educational or research use only: public documentation cites 10,000 points/day and 200 requests/day per IP/key; do not use for commercial traffic. |
| `cheaperinference` | `cinf` | Cheaper Inference | API key | [link](https://cheaperinference.com/?utm_source=omniroute) | — |
| `chenzk` | `chenzk` | Chenzk API | API key | [link](https://chenzk.top) | — |
| `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. |
| `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key <token>. |
| `cloudcode-one` | `cloudcode-one` | CloudCode.ONE | API key, aggregator | [link](https://cloudcode.one) | Published free models include glm-4.7-flash and glm-4.6v-flash; no numeric quota is published, and key creation may require credit or a coupon. |
| `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) |
| `clova-studio` | `clova` | Naver CLOVA Studio | API key | [link](https://api.ncloud-docs.com/docs/en/ai-naver-clovastudio-summary) | — |
| `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — |
| `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required |
| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. |
| `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api |
| `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — |
| `cursor-api` | `cua` | Cursor API | API key | [link](https://cursor.com/dashboard/api) | Paste a Cursor user API key (crsr_...) from cursor.com/dashboard/api. OmniRoute exchanges it for a session token on demand; no IDE or cursor-agent install is needed. Usage bills to the Cursor plan that owns the key. |
| `dahl` | `dahl` | Dahl | API key | [link](https://inference.dahl.global) | Click 'Add Account' to auto-generate a token, or add a manual API key. |
| `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — |
| `datarobot` | `datarobot` | DataRobot | API key, enterprise | [link](https://docs.datarobot.com) | Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/<id>. |
| `deepai` | `deepai` | DeepAI | API key, image | [link](https://deepai.org) | Use your DeepAI API key. Get one at deepai.org — requires a Pro subscription ($9.99/mo). |
| `deepinfra` | `deepinfra` | DeepInfra | API key | [link](https://deepinfra.com) | Free signup credits for API testing and model exploration |
| `deepseek` | `ds` | DeepSeek | API key | [link](https://platform.deepseek.com) | 5M free tokens on signup - no credit card required |
| `dgrid` | `dgrid` | DGrid | API key | [link](https://dgrid.ai) | DGrid Free Models Router: 10 requests/minute and 100 requests/day. A $5 lifetime top-up unlocks up to 20 requests/minute and 1,000 requests/day. |
| `dify` | `dify` | Dify | API key | [link](https://dify.ai) | Get API key from your Dify instance. |
| `digitalocean` | `digitalocean` | DigitalOcean | API key | [link](https://docs.digitalocean.com/products/ai-platform/) | — |
| `dit` | `dai` | DIT.ai | API key | [link](https://dit.ai) | Use your dit.ai API key in Authorization: Bearer <key>. Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1. |
| `doubao` | `doubao` | Doubao | API key | [link](https://doubao.com) | Get API key at console.volcengine.com |
| `dxnt` | `dxnt` | DXNT / DX Token | API key, aggregator | [link](https://www.dxnt.com) | Free accounts are documented at 100 calls/day; the quota may increase through invitations and can vary by account. |
| `electronhub` | `electronhub` | Electron Hub | API key, aggregator | [link](https://www.electronhub.ai) | Free plan: 5 RPM, $0.25 weekly credits and 10 Neutrinos/day for :free models; family budgets also apply. |
| `empower` | `empower` | Empower | API key, aggregator | [link](https://docs.empower.dev) | Bearer API key for the Empower OpenAI-compatible endpoint. |
| `factory` | `factory` | Factory | API key | [link](https://factory.ai) | Bearer API key for the Factory OpenAI-compatible gateway. |
| `fal-ai` | `fal` | Fal.ai | API key, image | [link](https://fal.ai) | — |
| `fastrouter` | `fastrouter` | FastRouter | API key, aggregator | [link](https://fastrouter.ai) | Models with the :free suffix allow 10 requests/day per organization and model; availability may change. |
| `featherless-ai` | `featherless` | Featherless AI | API key | [link](https://featherless.ai) | Free tier available — no credit card required |
| `fenayai` | `fenayai` | FenayAI | API key, aggregator | [link](https://fenayai.com) | Bearer API key for the FenayAI OpenAI-compatible gateway. |
| `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing |
| `free-ai` | `free-ai` | Free.ai | API key, aggregator | [link](https://free.ai) | 30,000 tokens/day cover self-hosted models after email verification. Usage beyond the pool can bill at raw cost, and premium external models are paid. |
| `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — |
| `freebuff` | `freebuff` | Freebuff | API key | [link](https://freebuff.com) | Enter Freebuff / Codebuff Auth Token (obtained via CLI login or automated harvester). |
| `freeinference` | `freeinference` | FreeInference | API key, aggregator | [link](https://freeinference.org) | Free research access without a card; non-Harvard applicants require manual approval and no numeric quota is publicly guaranteed. |
| `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. |
| `freetheai` | `fta` | FreeTheAi | API key, aggregator | [link](https://freetheai.xyz) | Join the FreeTheAi Discord to get your free API key. |
| `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required |
| `g4f-gemini` | `g4fgem` | g4f.space — Gemini | API key, aggregator | [link](https://g4f.space) | No auth required. Free tier is limited to 5 requests/minute — sign up at g4f.dev/members.html for higher limits. |
| `g4f-groq` | `g4fgroq` | g4f.space — Groq | API key, aggregator | [link](https://g4f.space) | No auth required. Free tier is limited to 5 requests/minute — sign up at g4f.dev/members.html for higher limits. |
| `g4f-nvidia` | `g4fnv` | g4f.space — NVIDIA | API key, aggregator | [link](https://g4f.space) | No auth required. Free tier is limited to 5 requests/minute — sign up at g4f.dev/members.html for higher limits. |
| `g4f-ollama` | `g4foll` | g4f.space — Ollama | API key, aggregator | [link](https://g4f.space) | No auth required. Free tier is limited to 5 requests/minute — sign up at g4f.dev/members.html for higher limits. |
| `g4f-pollinations` | `g4fpol` | g4f.space — Pollinations | API key, aggregator | [link](https://g4f.space) | No auth required. Free tier is limited to 5 requests/minute — sign up at g4f.dev/members.html for higher limits. |
| `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. |
| `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free tier available through Google AI Studio; current per-model quotas and regional limits apply |
| `getgoapi` | `ggo` | GoAPI | API key, aggregator | [link](https://api.getgoapi.com) | — |
| `gigachat` | `gigachat` | GigaChat (Sber) | API key | [link](https://developers.sber.ru) | — |
| `gitlab` | `gitlab` | GitLab Duo PAT | API key | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com. |
| `gitlawb` | `glb` | Gitlawb Opengateway (MiMo) | API key | [link](https://opengateway.gitlawb.com) | Free MiMo (xiaomi/mimo-v2.5) revoked 2026-05 — Opengateway is now a pay-as-you-go credit gateway; no recurring free model. |
| `gitlawb-gmi` | `glb-gmi` | Gitlawb Opengateway (GMI Cloud) | API key | [link](https://opengateway.gitlawb.com) | Free Nemotron promo ended 2026-06 — the GMI Cloud route is now pay-as-you-go credit only. |
| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — |
| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — |
| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — |
| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card |
| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. |
| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api |
| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn |
| `helixmind` | `helixmind` | HelixMind | API key, aggregator | [link](https://helixmind.online) | Previously circulated 3 RPM/50 RPD and no-card claims were not confirmed during the 2026-08-02 audit; current quota and billing require account verification. |
| `helyxai` | `helyxai` | Helyx AI | API key, aggregator | [link](https://helyxai.space) | Operational Free plan documents 100,000 tokens/day; the site's separate 2M+ marketing claim conflicts and is not treated as a quota guarantee. |
| `heroku` | `heroku` | Heroku AI | API key, enterprise | [link](https://www.heroku.com) | — |
| `huggingface` | `hf` | HuggingFace | API key | [link](https://huggingface.co) | Free Inference API for thousands of models (Whisper, VITS, SDXL…) |
| `hyperbolic` | `hyp` | Hyperbolic | API key | [link](https://hyperbolic.xyz) | $1-5 trial credits on signup for serverless inference |
| `ideogram` | `ideo` | Ideogram | API key | [link](https://ideogram.ai) | Get API key at ideogram.ai/docs/api |
| `iflytek` | `iflytek` | iFlytek Spark | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn |
| `inception` | `inception` | Inception | API key | [link](https://docs.inceptionlabs.ai) | 10M free tokens on signup, no credit card required. |
| `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available |
| `internlm` | `internlm` | InternLM (Intern-S1) | API key | [link](https://internlm.intern-ai.org.cn/) | Free monthly quota ~1M input / 3M output tokens (~10 RPM) |
| `jina-ai` | `jina` | Jina AI (Foundation API) | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for api.jina.ai — embeddings, rerank, classify, segment, and search. Dashboard keys take precedence over JINA_AI_API_KEY. This is not the Reader / r.jina.ai card and does not fetch URLs. |
| `jina-reader` | `jr` | Jina Reader (r.jina.ai) | API key | [link](https://jina.ai/reader) | Bearer API key for r.jina.ai URL-to-markdown (/v1/web/fetch only). Does not serve /v1/embeddings or /v1/rerank. The same Jina token as Foundation API works; OmniRoute reuses a jina-ai dashboard key or JINA_AI_API_KEY when this card is empty. |
| `kenari` | `kenari` | Kenari | API key | [link](https://kenari.id) | Use your Kenari API key (kn-...) in Authorization: Bearer <key>. Fully OpenAI-compatible. API base URL: https://kenari.id/v1. |
| `kie` | `kie` | KIE.AI | API key | [link](https://kie.ai) | — |
| `kilo-gateway` | `kg` | Kilo Gateway | API key, aggregator | [link](https://kilo.ai) | — |
| `kimi` | `kimi` | Kimi (Legacy Moonshot API) | API key | [link](https://platform.kimi.ai?aff=omniroute) | — |
| `kimi-coding-apikey` | `kmca` | Kimi Code API Key | API key | [link](https://www.kimi.com/code?aff=omniroute) | — |
| `lambda-ai` | `lambda` | Lambda AI | API key | [link](https://lambda.ai) | — |
| `laozhang` | `lz` | LaoZhang AI | API key, aggregator | [link](https://api.laozhang.ai) | — |
| `leonardo` | `leo` | Leonardo AI | API key, video | [link](https://leonardo.ai) | Get API key at leonardo.ai/developer |
| `liquid` | `liquid` | Liquid AI | API key | [link](https://liquid.ai) | Get API key at liquid.ai |
| `literouter` | `literouter` | LiteRouter | API key, aggregator | [link](https://literouter.com) | Free model variants use the :free suffix; daily credit limits vary by model and free input is capped at 5,000 tokens. |
| `llamagate` | `llamagate` | LlamaGate | API key | [link](https://llamagate.ai) | — |
| `llm-kiwi` | `llmkiwi` | LLM.Kiwi | API key, aggregator | [link](https://llm.kiwi) | Free plan exposes auto and hrLLM; the published 40 requests/hour limit applies to hrLLM. |
| `llm7` | `llm7` | LLM7.io | API key | [link](https://llm7.io) | Use any non-empty key (for example 'unused'). If older built-in models return model_unavailable, use Available Models → Import from /models or Auto-Sync; verified live model: gemini-3.1-flash-lite. |
| `llmgateway` | `llmgateway` | LLM Gateway | API key, aggregator | [link](https://llmgateway.io) | Hosted Free plan: free-priced models are limited to 5 requests per 10 minutes when the account has no credits. |
| `logfare` | `logfare` | Logfare | API key, aggregator | [link](https://logfare.ai) | Create a free account at https://logfare.ai/register (username/password, no email verification) to get an instant API key, then paste it here as a Bearer token. |
| `longcat` | `lc` | LongCat AI | API key | [link](https://longcat.chat/platform/docs) | Free: one-time 10M-token grant after account signup + KYC verification (LongCat-2.0). One-time only — not a recurring daily/monthly allowance. |
| `magnific` | `freepik` | Magnific | API key, image | [link](https://www.magnific.com) | Get an API key at magnific.com/user/api-keys (header x-magnific-api-key). Legacy Freepik developer keys still work. |
| `maritalk` | `maritalk` | Maritalk | API key | [link](https://www.maritaca.ai) | — |
| `meganova-ai` | `meganova-ai` | MegaNova AI | API key, aggregator | [link](https://meganova.ai) | Free signup without a card. Published Tier 1 per-model quotas total 550 requests/day; they are not a shared global pool, and paid overage can apply if enabled. |
| `meta-llama` | `meta` | Meta Llama API | API key | [link](https://llama.developer.meta.com) | — |
| `minimax` | `minimax` | Minimax Coding | API key, video | [link](https://www.minimax.io) | — |
| `minimax-cn` | `minimax-cn` | Minimax (China) | API key | [link](https://www.minimaxi.com) | — |
| `mistral` | `mistral` | Mistral | API key | [link](https://mistral.ai) | Free Experiment tier: rate-limited access to all models, no credit card required |
| `mixedbread` | `mxbai` | Mixedbread AI | API key | [link](https://www.mixedbread.com) | Bearer API key for the Mixedbread embeddings API. |
| `mixlayer` | `mixlayer` | Mixlayer | API key, aggregator | [link](https://www.mixlayer.com) | The qwen/qwen3.5-4b-free model is free for prototyping and rate-limited; no fixed public RPM or daily quota is confirmed. |
| `mnn-ai` | `mnn-ai` | MNN AI | API key, aggregator | [link](https://mnnai.ru) | Free plan: $1 monthly credits, 10 RPM and access only to models marked Free. |
| `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://<workspace>--<app>.modal.run/v1. |
| `modelscope` | `ms` | ModelScope | API key | [link](https://modelscope.cn) | Free tier via ModelScope API-Inference — Alibaba account required. |
| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | ⚠️ **DEPRECATED.** Monster API shuttered operations on 2026-06-30. Use alternative OpenAI-compatible providers. |
| `moonshot` | `moonshot` | Kimi | API key | [link](https://platform.kimi.ai?aff=omniroute) | — |
| `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 |
| `muse-code` | `mc` | Muse Code (Meta) | API key | [link](https://github.com/meta-llama/llama-stack) | Use your META_API_KEY env var as a Bearer token. Muse Code CLI uses the OpenAI Responses API wire format (POST /responses). |
| `naga-ac` | `naga` | Naga.ac | API key, aggregator | [link](https://naga.ac) | Get API key at naga.ac — Google/GitHub/Discord signup available. |
| `naga-ai` | `naga-ai` | Naga AI | API key, aggregator | [link](https://naga.ac) | Models marked :free are publicly listed, but no numeric quota is confirmed. Naga's policy warns that free-tier prompts and outputs may be collected or used for training. |
| `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — |
| `nara` | `nara` | NaraRouter | API key | [link](https://bynara.id) | Get a free API key via NaraRouter's Telegram channel, then paste it here as a Bearer token. |
| `navy` | `navy` | NavyAI | API key | [link](https://api.navy) | Create a free API key from the NavyAI dashboard, then paste it here as a Bearer token. |
| `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing |
| `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token <key>. OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu/<model>/chatbot by default. |
| `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai |
| `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. |
| `novita` | `novita` | Novita AI | API key, video, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) |
| `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing |
| `nube` | `nube` | Nube.sh | API key | [link](https://nube.sh) | — |
| `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) |
| `oci` | `oci` | OCI Generative AI | API key, enterprise | [link](https://www.oracle.com/artificial-intelligence/generative-ai) | Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai.<region>.oci.oraclecloud.com/openai/v1/. |
| `ofoxai` | `ofoxai` | OfoxAI | API key, aggregator | [link](https://ofox.ai) | The current catalog advertises 10+ free models without a public numeric quota; review upstream provenance, retention and training terms before production use. |
| `ollama-cloud` | `ollamacloud` | Ollama Cloud | API key | [link](https://ollama.com/settings/keys) | — |
| `openadapter` | `oad` | OpenAdapter | API key | [link](https://openadapter.dev) | Use your OpenAdapter API key in Authorization: Bearer sk-cv-<key>. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1. |
| `openai` | `openai` | OpenAI | API key | [link](https://platform.openai.com) | — |
| `opencode-go` | `opencode-go` | OpenCode Go | API key | [link](https://opencode.ai/go) | — |
| `opencode-zen` | `opencode-zen` | OpenCode Zen | API key | [link](https://opencode.ai/zen) | — |
| `openference-api` | `ofa` | Openference API | API key | [link](https://openference.com) | Free plan: 3-day trial with open-source models — no credit card required |
| `openrouter` | `openrouter` | OpenRouter | API key, aggregator | [link](https://openrouter.ai) | Free models at $0/token with :free suffix - 20 RPM / 200 RPD |
| `openvecta` | `openvecta` | OpenVecta | API key | [link](https://openvecta.com) | Free credits on signup for OpenAI-compatible inference across LLMs, embeddings, and reasoning models |
| `orcarouter` | `orcarouter` | OrcaRouter | API key | [link](https://www.orcarouter.ai) | — |
| `ovhcloud` | `ovh` | OVHcloud AI | API key | [link](https://www.ovhcloud.com) | — |
| `perplexity` | `pplx` | Perplexity | API key | [link](https://www.perplexity.ai) | — |
| `piapi` | `pi` | PiAPI | API key, aggregator | [link](https://piapi.ai) | — |
| `pioneer` | `pn` | Pioneer AI | API key | [link](https://pioneer.ai) | $75 free usage credits — no credit card required |
| `plamo` | `plamo` | PLaMo | API key | [link](https://plamo.preferredai.jp/api) | — |
| `poe` | `poe` | Poe | API key, aggregator | [link](https://creator.poe.com/api-reference) | Bearer API key for the Poe OpenAI-compatible API. |
| `poixe-ai` | `poixe-ai` | Poixe AI | API key, aggregator | [link](https://poixe.com) | Current public free limits are small and model-group specific: 2 RPM/5 RPD for large-cup models and 20 RPM/50 RPD for small-cup models. |
| `pollinations` | `pol` | Pollinations AI | API key, video | [link](https://pollinations.ai) | Anonymous/keyless access to the documented free models is best-effort. Local v3.8.50 verification (2026-07-31) returned 401 via OmniRoute and Cloudflare 1010 on direct upstream probes from the same network. Premium models still require a Pollinations API key from enter.pollinations.ai. |
| `poolside` | `poolside` | Poolside | API key | [link](https://poolside.ai) | Laguna S 2.1 and XS 2.1 are free during Preview; no public numeric quota is published. |
| `predibase` | `predibase` | Predibase | API key | [link](https://predibase.com) | ⚠️ **DEPRECATED.** serving.app.predibase.com no longer resolves (sweep 2026-06-19); the managed serving API appears discontinued. |
| `publicai` | `publicai` | PublicAI | API key | [link](https://publicai.co) | Requires an API key — one-time signup credit, then paid |
| `qianfan` | `qianfan` | Baidu Qianfan | API key | [link](https://cloud.baidu.com/product-s/qianfan_home) | — |
| `qiniu` | `qiniu` | Qiniu | API key | [link](https://www.qiniu.com) | — |
| `qwen-cloud` | `qwc` | Qwen Cloud | API key | [link](https://www.qwencloud.com/) | — |
| `qwen-cloud-token-plan` | `qct` | Qwen Cloud Token Plan | API key | [link](https://www.qwencloud.com/pricing/token-plan) | — |
| `recraft` | `recraft` | Recraft | API key, image | [link](https://recraft.ai) | — |
| `regolo` | `regolo` | Regolo AI | API key | [link](https://regolo.ai) | Get your Regolo API key from regolo.ai, then paste it here as a Bearer token. |
| `reka` | `reka` | Reka | API key | [link](https://docs.reka.ai/chat/overview) | Use your Reka API key. OmniRoute supports the OpenAI-compatible base URL https://api.reka.ai/v1 and sends both Authorization and X-Api-Key headers for compatibility. |
| `requesty` | `requesty` | Requesty | API key | [link](https://requesty.ai) | Free tier ~200 requests/day - multi-model routing gateway (300+ models) |
| `routeway` | `routeway` | Routeway | API key | [link](https://routeway.ai) | Create a free API key at routeway.ai, then paste it here as a Bearer token. |
| `runwayml` | `runway` | Runway | API key, video | [link](https://docs.dev.runwayml.com) | Use your Runway API key in Authorization: Bearer <key>. OmniRoute targets the current Runway API at https://api.dev.runwayml.com/v1 and sends the required X-Runway-Version header automatically. |
| `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required |
| `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. |
| `sarvam` | `sarvam` | Sarvam AI | API key | [link](https://docs.sarvam.ai) | ₹1,000 in free signup credits — never expire |
| `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B |
| `sealion` | `sealion` | SEA-LION | API key | [link](https://sea-lion.ai) | Sign in at sea-lion.ai with Google (no card, no region wall), create an API key, then paste it here. |
| `segmind` | `segmind` | Segmind | API key, image, video | [link](https://segmind.com) | Use your Segmind API key in the x-api-key header. OmniRoute targets https://api.segmind.com/v1/<model> and returns the generated image/video bytes directly. |
| `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn |
| `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus currently listed $0 models after identity verification; availability and limits may change |
| `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — |
| `sparkdesk` | `sparkdesk` | SparkDesk | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn |
| `speka` | `speka` | Speka AI | API key, aggregator | [link](https://speka.me) | Free plan: $1 monthly usage, 10 RPM, one API key and access to open models and the playground; no card required. |
| `stability-ai` | `stability` | Stability AI | API key, image | [link](https://stability.ai) | — |
| `stepfun` | `stepfun` | StepFun | API key | [link](https://stepfun.com) | Get API key at platform.stepfun.com |
| `sumopod` | `sumopod` | SumoPod | API key | [link](https://ai.sumopod.com) | Use your SumoPod API key (sk-...) in Authorization: Bearer <key>. Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. |
| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) |
| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — |
| `tabitoken` | `tabitoken` | TabiToken | API key, aggregator | [link](https://tabitoken.com) | — |
| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com |
| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. |
| `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys |
| `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | — |
| `token-kiosk` | `tk` | Token Kiosk | API key | [link](https://agent-router.gaib.ai) | Use your Token Kiosk API key in Authorization: Bearer <key>. Fully OpenAI-compatible gateway. API base URL: https://agent-router.gaib.ai/v1. |
| `tokenreply` | `tokenreply` | TokenReply | API key, aggregator | [link](https://www.tokenreply.com) | Free-tagged models have model- and campaign-specific daily limits; no fixed global free quota is published. |
| `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer <key>. Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. |
| `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — |
| `typhoon` | `typhoon` | Typhoon | API key | [link](https://docs.opentyphoon.ai) | Free API key with a 5 req/s and 200 req/m rate limit. |
| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) |
| `uncloseai` | `unc` | UncloseAI | API key | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ. |
| `unorouter` | `unorouter` | UnoRouter | API key, aggregator | [link](https://unorouter.ai) | Models with the :free suffix do not debit balance; limit is 1 request/minute per free model per user. |
| `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — |
| `v0-vercel` | `v0` | v0 (Vercel) | API key | [link](https://v0.dev) | — |
| `venice` | `venice` | Venice.ai | API key | [link](https://venice.ai) | — |
| `vercel-ai-gateway` | `vag` | Vercel AI Gateway | API key, aggregator | [link](https://vercel.com/docs/ai-gateway) | — |
| `vertex` | `vertex` | Vertex AI | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide Service Account JSON or OAuth access_token |
| `vertex-partner` | `vp` | Vertex AI Partners | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide the same Service Account JSON used for Vertex AI partner models. |
| `void-ai` | `void-ai` | Void AI | API key, aggregator | [link](https://voidai.app) | The public model catalog marks some models with a free plan requirement, but access is conditional and no numeric quota is confirmed. |
| `volcengine` | `volcengine` | Volcengine | API key | [link](https://www.volcengine.com) | — |
| `voyage-ai` | `voyage` | Voyage AI | API key, embed/rerank | [link](https://www.voyageai.com) | Bearer API key for Voyage AI embeddings and rerank APIs. |
| `wafer` | `wafer` | Wafer AI | API key | [link](https://wafer.ai) | — |
| `wandb` | `wandb` | Weights & Biases Inference | API key | [link](https://wandb.ai) | — |
| `watsonx` | `watsonx` | IBM watsonx.ai Gateway | API key, enterprise | [link](https://www.ibm.com/products/watsonx-ai) | Use your watsonx bearer token. Base URL can be https://<region>.ml.cloud.ibm.com/ml/gateway/v1/ or a self-managed /ml/gateway/v1 endpoint. |
| `writer` | `writer` | Writer | API key | [link](https://dev.writer.com) | — |
| `x5lab` | `x5lab` | X5Lab | API key | [link](https://x5lab.dev) | Use your X5Lab API key (x5-...) in Authorization: Bearer <key>. Fully OpenAI-compatible. API base URL: https://api.x5lab.dev/v1. |
| `xai` | `xai` | xAI (Grok) | API key | [link](https://x.ai) | Use an official xAI API key, or sign in with xAI OAuth. Grok Build JWT sessions remain a separate provider. |
| `xiaomi-mimo` | `mimo` | Xiaomi MiMo | API key | [link](https://mimo.mi.com) | — |
| `xiaomi-mimo-token-plan` | `mimotp` | Xiaomi MiMo Token Plan | API key | [link](https://mimo.mi.com) | — |
| `yi` | `yi` | Yi (01.AI) | API key | [link](https://01.ai) | Get API key at platform.lingyiwanwu.com |
| `yolo-auto` | `yolo-auto` | Yolo-Auto | API key, aggregator | [link](https://yolo-auto.com) | Free API access is request-limited and intended for testing; no numeric daily quota is published and free access is not promised indefinitely. |
| `zai` | `zai` | Z.AI | API key | [link](https://open.bigmodel.cn) | — |
| `zenmux` | `zm` | ZenMux | API key | [link](https://zenmux.ai) | Use your ZenMux API key in Authorization: Bearer <key>. ZenMux is fully OpenAI-compatible. Base URL: https://zenmux.ai/api/v1. |
| `zerolimitai` | `zerolimitai` | ZeroLimitAI | API key, aggregator | [link](https://www.zerolimitai.com) | Temporary free trial is advertised, but official pages conflict between 3 and 7 days; a 100-calls/day claim is not treated as permanent. |
| `zylo-api` | `zylo` | Zylo API | API key, aggregator | [link](https://zyloai.net) | Basic plan: 10 RPM, 7,200 requests/day and 200,000 tokens/day; limited to Basic text models. |
## Local Providers (14)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
| `comfyui` | `comfyui` | ComfyUI | Local | [link](https://github.com/comfyanonymous/ComfyUI) | No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188). |
| `docker-model-runner` | `dmr` | Docker Model Runner | Local, self-hosted | [link](https://docs.docker.com/ai/model-runner/) | API key optional. Configure the local Docker Model Runner OpenAI-compatible base URL (default: http://localhost:12434/v1). |
| `lemonade` | `lemonade` | Lemonade Server | Local, self-hosted | [link](https://lemonade-server.ai) | API key optional. Configure the local Lemonade OpenAI-compatible base URL (default: http://localhost:13305/api/v1). |
| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. |
| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). |
| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). |
| `mlx-gemma` | `mlx-gemma` | MLX Gemma 26B | Local, self-hosted | [link](https://github.com/ml-explore/mlx) | No API key required. Runs mlx-lm server locally on port 11435. Requires uv and mlx-lm installed. Model: mlx-community/gemma-4-26B-A4B-it-qat-q4_0-mlx-aligned (~15.9GB peak memory). |
| `mlx-qwen` | `mlx-qwen` | MLX Qwen 3.8 27B | Local, self-hosted | [link](https://github.com/ml-explore/mlx) | No API key required. Runs mlx-lm server locally on port 11436. Requires uv and mlx-lm installed. Model: maglun/Qwen3.8-27B-MLX-Mixed-3.80bpw (~13.1GB peak memory). |
| `ollama-local` | `ollama` | Ollama | Local, self-hosted | [link](https://ollama.com) | No API key required. Ollama runs locally — configure its OpenAI-compatible base URL (default: http://localhost:11434/v1) and make sure Ollama is running before connecting. |
| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). |
| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). |
| `triton` | `triton` | NVIDIA Triton | Local, self-hosted | [link](https://developer.nvidia.com/triton-inference-server) | API key optional. Configure the Triton OpenAI-compatible base URL (default: http://localhost:8000/v1). |
| `vllm` | `vllm` | vLLM | Local, self-hosted | [link](https://github.com/vllm-project/vllm) | API key optional. Configure the local vLLM OpenAI-compatible base URL (default: http://localhost:8000/v1). |
| `xinference` | `xinference` | XInference | Local, self-hosted | [link](https://inference.readthedocs.io) | API key optional. Configure the local XInference OpenAI-compatible base URL (default: http://localhost:9997/v1). |
## Search Providers (13)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
| `brave-search` | `brave-search` | Brave Search | Search | [link](https://brave.com/search/api) | Subscription token from Brave Search API dashboard |
| `exa-search` | `exa-search` | Exa Search | Search | [link](https://exa.ai) | API key from dashboard.exa.ai |
| `firecrawl` | `fc` | Firecrawl | Search | [link](https://firecrawl.dev) | API key from firecrawl.dev/app/api-keys (or set your self-hosted Firecrawl base URL) |
| `google-pse-search` | `google-pse` | Google Programmable Search | Search | [link](https://developers.google.com/custom-search/v1/overview) | Requires a Google API key and your Programmable Search Engine ID (cx) |
| `linkup-search` | `linkup` | Linkup Search | Search | [link](https://docs.linkup.so) | Bearer API key from the Linkup dashboard |
| `ollama-search` | `ollama-search` | Ollama Search | Search | [link](https://ollama.com/settings/keys) | Same API key as Ollama Cloud (from ollama.com/settings/keys) |
| `perplexity-search` | `pplx-search` | Perplexity Search | Search | [link](https://docs.perplexity.ai/guides/search-quickstart) | Same API key as Perplexity (pplx-...) |
| `searchapi-search` | `searchapi` | SearchAPI | Search | [link](https://www.searchapi.io/docs/google) | API key from SearchAPI (query param or Bearer auth) |
| `searxng-search` | `searxng` | SearXNG Search | Search | [link](https://docs.searxng.org) | API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access. |
| `serper-search` | `serper-search` | Serper Search | Search | [link](https://serper.dev) | API key from serper.dev dashboard |
| `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) |
| `x-search` | `x_search` | X Search (Grok) | Search | [link](https://docs.x.ai/developers/tools/x-search) | SuperGrok OAuth (xai-oauth) or xAI API key. This is Grok X Search, not the X Developer MCP. |
| `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/business/api/) | X-API-Key from the You.com platform dashboard |
## Audio-only Providers (12)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
| `assemblyai` | `aai` | AssemblyAI | Audio | [link](https://assemblyai.com) | — |
| `aws-polly` | `polly` | AWS Polly | Audio | [link](https://aws.amazon.com/polly/) | Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region. |
| `cartesia` | `cartesia` | Cartesia | Audio | [link](https://cartesia.ai) | — |
| `deepgram` | `dg` | Deepgram | Audio | [link](https://deepgram.com) | — |
| `elevenlabs` | `el` | ElevenLabs | Audio | [link](https://elevenlabs.io) | — |
| `fishaudio` | `fishaudio` | Fish Audio | Audio | [link](https://fish.audio) | — |
| `gladia` | `gladia` | Gladia | Audio | [link](https://gladia.io) | — |
| `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — |
| `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — |
| `rev-ai` | `revai` | Rev AI | Audio | [link](https://www.rev.ai) | — |
| `soniox` | `sx` | Soniox | Audio | [link](https://soniox.com) | — |
| `speechmatics` | `sm` | Speechmatics | Audio | [link](https://www.speechmatics.com) | Free tier — 8 hours/month, no credit card required. Batch (async) mode only. |
## Upstream Proxy Providers (2)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
| `9router` | `nr` | 9router | Upstream proxy | [link](https://www.npmjs.com/package/9router) | — |
| `cliproxyapi` | `cpa` | CLIProxyAPI | Upstream proxy | [link](https://github.com/router-for-me/CLIProxyAPI) | — |
## Cloud Agent Providers (3)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
| `codex-cloud` | `codex-cloud` | Codex Cloud | Cloud agent | [link](https://openai.com/codex) | OpenAI API key with Codex Cloud task access. |
| `devin` | `devin` | Devin | Cloud agent | [link](https://devin.ai) | Devin API key for cloud agent sessions. |
| `jules` | `jules` | Google Jules | Cloud agent | [link](https://jules.google) | Jules API key for creating and managing cloud coding tasks. |
## System Providers (1)
| ID | Alias | Name | Tags | Website | Notes |
|----|-------|------|------|---------|-------|
| `auto` | `auto` | Auto (Zero-Config) | System | — | — |
## Sources of truth
- Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts)
- Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts)
- Executors: [`open-sse/executors/`](../../open-sse/executors/) (106 implementations)
- Translators: [`open-sse/translator/`](../../open-sse/translator/)
## See Also
- [FREE_TIERS.md](./FREE_TIERS.md) — curated free-tier guide
- [USER_GUIDE.md](../guides/USER_GUIDE.md) — provider setup walkthrough
- [ARCHITECTURE.md](../architecture/ARCHITECTURE.md) — overall architecture

173
README.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 → 349 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 350 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 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 | **342** | 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="#-349-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. 349 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 349 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 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 110 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: 349 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project&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>
@@ -513,8 +513,6 @@ Pix copia-e-cola:
<br/>
<p><strong>Developer notes:</strong> The project may generate a local <code>.env</code> file during npm install/postinstall for developer convenience. This file is intentionally ignored via <code>.gitignore</code> (see <code>.gitignore</code>) and must never be committed — if accidentally committed, rotate any exposed secrets and remove the file from history. See <a href="docs/DEVELOPER-ENVIRONMENT.md">docs/DEVELOPER-ENVIRONMENT.md</a> for guidance on managing local environment files and secrets.</p>
## 📡 OmniRoute Radar
The main free-tier headline remains **~1.53B tokens/month** from the documented,
@@ -548,7 +546,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 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)
- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute launch` / `launch-codex` are zero-config. → [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)
@@ -557,9 +555,9 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
- **🧠 Memory you control** — off by default, opt-in int8 vector quantization + typed decay, per-request `x-omniroute-no-memory`. → [Memory](docs/frameworks/MEMORY.md)
- **🛡️ Security** — prompt-injection guard on every LLM route (red-team suite), opt-in credential-masking guardrail (redacts leaked API keys/secrets in both directions), free DuckDuckGo last-resort web search, and an optional OIDC login gate for the dashboard (password login always stays available). → [Guardrails](docs/security/GUARDRAILS.md)
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
- **🎨 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 **350-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)
@@ -612,41 +610,19 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
<b> also works with</b> · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
</div>
<sub>📖 Per-tool setup for all 34 tools (26 CLI Code's + 8 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
<sub>📖 Per-tool setup for all 33 tools (25 CLI Code's + 8 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
</div>
<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">
## 🌐 349 AI Providers — 90+ Free
## 🌐 340 AI Providers — 90+ Free
</div>
> The most complete catalog of any open-source router: **349 providers**, **90+ with a free tier**, **56 free forever**.
> The most complete catalog of any open-source router: **340 providers**, **90+ with a free tier**, **56 free forever**.
<div align="center">
@@ -726,7 +702,6 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<tr><td align="left" nowrap>📱 <b>Android (Termux)</b></td><td align="left" nowrap><code>pkg install nodejs && npx -y omniroute</code></td><td align="left">Runs <b>on your phone</b>, 24/7, no root</td></tr>
<tr><td align="left" nowrap>📲 <b>PWA</b></td><td align="left" nowrap>"Add to Home Screen"</td><td align="left">Fullscreen, offline, installable from browser</td></tr>
<tr><td align="left" nowrap>🧩 <b>OpenCode plugin</b></td><td align="left" nowrap><code>@omniroute/opencode-provider</code></td><td align="left">Native OpenCode integration</td></tr>
<tr><td align="left" nowrap>🤖 <b>VS Code Copilot Chat</b></td><td align="left" nowrap>install <b>OmniCopilot</b> extension</td><td align="left">Every OmniRoute model in the native Copilot Chat picker — stable &amp; Insiders</td></tr>
<tr><td align="left" nowrap>🛠️ <b>From source</b></td><td align="left" nowrap><code>npm install && npm run dev</code></td><td align="left">Hack on it, contribute</td></tr>
</table>
@@ -736,35 +711,6 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<div align="center">
### 🧩 New: OmniRoute inside VS Code's native Copilot Chat
</div>
> No new sidebar, no new chat UI — every model OmniRoute serves shows up right in the
> **Copilot Chat model picker you already use**. Since VS Code 1.122, provider models work
> without a GitHub sign-in or a Copilot subscription — agent mode, tool calling and vision, for
> free.
Install the **[OmniCopilot](https://github.com/diegosouzapw/OmniCopilot)** extension, point it
at your OmniRoute server (defaults to `localhost:20128`), then open Copilot Chat → model picker
**Manage Models…****OmniRoute**.
<table>
<tr><th align="left">Store</th><th align="left">Link</th><th align="left">Works with</th></tr>
<tr><td align="left" nowrap>🧩 <b>VS Code Marketplace</b></td><td align="left"><a href="https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot">Install →</a></td><td align="left">VS Code — stable &amp; Insiders</td></tr>
<tr><td align="left" nowrap>🔓 <b>Open VSX Registry</b></td><td align="left"><a href="https://open-vsx.org/extension/diegosouzapw/omnicopilot">Install →</a></td><td align="left">Cursor, Windsurf, VSCodium, Theia, code-server, Gitpod, Antigravity, Kiro…</td></tr>
</table>
From inside the editor: open the **Extensions** view, search **"OmniRoute"**, click **Install**
— 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">
## 🔒 Private & Local-First
</div>
@@ -821,7 +767,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo
<table>
<tr><th align="left">Interface</th><th align="left">Endpoint / command</th><th align="left">Use it for</th></tr>
<tr><td align="left" nowrap>🧰 <b>MCP (stdio)</b></td><td align="left" nowrap><code>omniroute --mcp</code></td><td align="left">Plug into Claude Desktop, Cursor, any MCP client</td></tr>
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>110 tools</b>, 33 scopes, full audit trail</td></tr>
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>109 tools</b>, 33 scopes, full audit trail</td></tr>
<tr><td align="left" nowrap>📡 <b>MCP (SSE)</b></td><td align="left" nowrap><code>/api/mcp/sse</code></td><td align="left">Streaming MCP transport</td></tr>
<tr><td align="left" nowrap>🤝 <b>A2A</b></td><td align="left" nowrap><code>/.well-known/agent.json</code></td><td align="left">Agent-to-agent, <b>JSON-RPC 2.0</b> + SSE, 6 skills</td></tr>
<tr><td align="left" nowrap>🌐 <b>REST API</b></td><td align="left" nowrap><code>/v1/*</code></td><td align="left">OpenAI-compatible — chat, embeddings, images, audio, OCR</td></tr>
@@ -877,7 +823,7 @@ Engines run in pipeline order; each is independently toggleable and configurable
<tr><td align="center" nowrap>9</td><td align="left" nowrap><b>Aggressive</b></td><td align="left">Summarization + progressive aging of old turns</td></tr>
<tr><td align="center" nowrap>10</td><td align="left" nowrap><b>LLMLingua-2</b></td><td align="left">ML semantic pruning via MobileBERT ONNX — code-safe, async</td></tr>
<tr><td align="center" nowrap>11</td><td align="left" nowrap><b>Ultra</b></td><td align="left">Heuristic token pruning with an optional small-model (SLM) tier</td></tr>
<tr><td align="center" nowrap>12</td><td align="left" nowrap><b>OmniGlyph</b></td><td align="left">Experimental context-as-image encoding for measured Claude Fable 5 on the direct Anthropic wire; GPT 5.6 transformers remain fail-closed pending provider receipts. Four compression profiles (aggressive default, balanced, coding-safe, passthrough) (most aggressive; opt-in)</td></tr>
<tr><td align="center" nowrap>12</td><td align="left" nowrap><b>OmniGlyph</b></td><td align="left">Experimental context-as-image encoding routed to Claude Fable 5 (most aggressive; opt-in)</td></tr>
</table>
Code blocks, URLs and structured data are **always preserved** byte-perfect. **One-click presets** combine the engines:
@@ -988,41 +934,12 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
-p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
```
`:latest` follows the highest **published** stable SemVer. It does not track git `main`. Pin `:X.Y.Z` for GitOps. See [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).The image pins **`OMNIROUTE_MEMORY_MB=1024`**. That is enough for the dashboard and a light chat. **Coding agents** (`POST /v1/responses` from Claude Code, Codex, Grok, …) need a much larger V8 heap or the process `FATAL ERROR`s at ~12GiB under two overlapping long contexts. Size the container above the heap (native buffers sit outside V8):
| Workload | Heap (`-e OMNIROUTE_MEMORY_MB`) | Container (`--memory`) |
| ----------------------------------- | ------------------------------- | ---------------------- |
| Dashboard / light chat | `1024` (image default) | ≥2g |
| One coding agent | `8192` | ≥10g |
| Two concurrent long `/v1/responses` | `10240``12288` | ≥1216g |
```bash
docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
-e OMNIROUTE_MEMORY_MB=8192 --memory=10g \
-p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
```
Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-ram-for-coding-agents).
> **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and
> `diegosouzapw/omniroute:next-web` follow the current default `release/v*`
> branch. These mutable tags are intended only for testing unreleased fixes and
> are **not supported for production**. See
> [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).
**🥟 Bun**
Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection:
- **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`.
- **Automatic Webpack bundler selection**: Development (`bun run dev`) and production builds (`bun run build`) automatically detect Bun and disable Turbopack in favor of Webpack to prevent native V8 binding incompatibilities.
- **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`).
```bash
# Install and run with Bun
bun install
bun run dev
```
**🛠️ From source**
```bash
@@ -1101,69 +1018,31 @@ same process on one port, so there is no separate CLI-only package today.
</div>
## 📹 Video Guides
<div align="center">
<sub>Dados de cobertura social em 2026-08-17 · YT: 741 | TT: 137 | IG: 124 · Frescor (dias): YT 0 · TT 14 · IG 15</sub>
<table>
<tr>
<td align="center" width="320">
<a href="https://www.instagram.com/reel/Da8ZthUPK98/">
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+nick_saraev&font=montserrat&bold=true" alt="Instagram Reel" width="300"/>
</a><br/>
<b>🎬 #1 — Instagram</b><br/>
<sub>nick_saraev — 1,628,910 views</sub>
<td align="center" width="264">
<a href="https://www.youtube.com/watch?v=Rxdc36yUyOQ"><img src="https://img.youtube.com/vi/Rxdc36yUyOQ/maxresdefault.jpg" alt="Guia em Português" width="260"/></a><br/>
<b>🇧🇷 Português</b><br/><sub>Guia completo</sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=QucgvbO5gsM">
<img src="https://img.youtube.com/vi/QucgvbO5gsM/maxresdefault.jpg" alt="YouTube — Vaibhav Sisinty" width="300"/>
</a><br/>
<b>🎬 #2 — YouTube</b><br/>
<sub>Vaibhav Sisinty — 373,084 views</sub>
<td align="center" width="264">
<a href="https://www.youtube.com/watch?v=CMzyOiUyEVc"><img src="https://img.youtube.com/vi/CMzyOiUyEVc/maxresdefault.jpg" alt="English Guide" width="260"/></a><br/>
<b>🇺🇸 English</b><br/><sub>Complete walkthrough</sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/shorts/fZIBK_4fKq8">
<img src="https://img.youtube.com/vi/fZIBK_4fKq8/maxresdefault.jpg" alt="YouTube Shorts" width="300"/>
</a><br/>
<b>🎬 #3 — YouTube Shorts</b><br/>
<sub>Nick Automates — 207,714 views</sub>
</td>
<td align="center" width="320">
<a href="https://www.tiktok.com/@milesreevesai/video/7667980059189366019">
<img src="https://placehold.co/320x180/111827/FFFFFF?text=TikTok+Top+1&font=montserrat&bold=true" alt="TikTok Thumbnail" width="300"/>
</a><br/>
<b>🎬 #4 — TikTok</b><br/>
<sub>milesreevesai — 620,400 views</sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=LkP6ocAoQkk">
<img src="https://img.youtube.com/vi/LkP6ocAoQkk/maxresdefault.jpg" alt="Valency Labs" width="300"/>
</a><br/>
<b>🎬 #5 — YouTube</b><br/>
<sub>Valency Labs — 135,974 views</sub>
<td align="center" width="264">
<a href="https://www.youtube.com/watch?v=il_5Ii6v4-Y"><img src="https://img.youtube.com/vi/il_5Ii6v4-Y/maxresdefault.jpg" alt="Руководство" width="260"/></a><br/>
<b>🇷🇺 Русский</b><br/><sub>Полное руководство</sub>
</td>
</tr>
</table>
</div>
**Ranking completo (`v > 0`, maior alcance):**
| #1 | #2 | #3 | #4 | #5 |
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| [nick_saraev — Instagram](https://www.instagram.com/reel/Da8ZthUPK98/) — **1,628,910** | [milesreevesai — TikTok](https://www.tiktok.com/@milesreevesai/video/7667980059189366019) — **620,400** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=QucgvbO5gsM) — **373,084** | [Nick Automates — YouTube Shorts](https://www.youtube.com/shorts/fZIBK_4fKq8) — **207,714** | [midudev — TikTok](https://www.tiktok.com/@midudev/video/7664636453544152342) — **177,800** |
| #6 | #7 | #8 | #9 | #10 |
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| [theopenstack — Instagram](https://www.instagram.com/reel/DaSs65mMrHk/) — **155,453** | [t.ghoush.ai — TikTok](https://www.tiktok.com/@t.ghoush.ai/video/7669497680527248656) — **152,800** | [Valency Labs — YouTube](https://www.youtube.com/watch?v=LkP6ocAoQkk) — **135,974** | [Asati — YouTube](https://www.youtube.com/watch?v=JjPtJcqwhqg) — **126,130** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=NuNDpeZYQ28) — **122,672** |
Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações conhecidas · 595 perfis/canais · 13+ idiomas · 13+ criadores.
<div align="center">
> 🎬 **Made a video about OmniRoute?** Open an [issue](https://github.com/diegosouzapw/OmniRoute/issues/new) or [discussion](https://github.com/diegosouzapw/OmniRoute/discussions) with the link — we'll feature it here.
<br/>
</div>
<div align="center">
@@ -1201,7 +1080,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) — 120 domain modules, 159 migrations</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 117 domain modules, 148 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>
@@ -1524,7 +1403,7 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router]
<table>
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
<tr><td nowrap><b><a href="https://github.com/toon-format/toon">TOON</a></b></td><td align="center">24.9k</td><td>Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage.</td></tr>
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf">GCF Graph Compact Format</a></b></td><td align="center">22</td><td>First inspired our tabular compaction stage; now its zero-dependency, lossless generic-profile encoder is <b>vendored directly</b> as the Headroom codec (MIT, SPDX-marked), with later numeric-domain and count-mismatch correctness fixes.</td></tr>
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf">GCF Graph Compact Format</a></b></td><td align="center">22</td><td>First inspired our tabular compaction stage; now its zero-dependency, lossless generic-profile encoder is <b>vendored directly</b> as the Headroom codec (MIT, SPDX-marked), current with GCF spec v3.2.</td></tr>
<tr><td nowrap><b><a href="https://github.com/ooples/token-optimizer-mcp">token-optimizer-mcp</a></b></td><td align="center">444</td><td>Brotli/SQLite cache + per-session context-delta — inspired our <code>session-dedup</code> engine.</td></tr>
<tr><td nowrap><b><a href="https://github.com/Mibayy/token-savior">token-savior</a></b></td><td align="center">1.1k</td><td>Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction.</td></tr>
<tr><td nowrap><b><a href="https://github.com/ppgranger/token-saver">token-saver</a></b></td><td align="center">117</td><td>Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip.</td></tr>

View File

@@ -30,60 +30,20 @@ export function register_combos(parent) {
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});
tag.command("get-api-combos-id-")
.description("Get combo by ID")
.requiredOption("--id <id>", "")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});
tag.command("put-api-combos-id-")
.description("Update combo")
.requiredOption("--id <id>", "")
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
let body;
if (opts.body) {
body = opts.body.startsWith("@")
? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
: JSON.parse(opts.body);
}
const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});
tag.command("patch-api-combos-id-")
.description("Update combo")
.requiredOption("--id <id>", "")
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
let body;
if (opts.body) {
body = opts.body.startsWith("@")
? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
: JSON.parse(opts.body);
}
const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const res = await apiFetch(url, { method: "PATCH", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);
});
tag.command("delete-api-combos-id-")
.description("Delete combo")
.requiredOption("--id <id>", "")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
let url = "/api/combos/{id}";
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = res.ok ? await res.json() : await res.text();
emit(data, gOpts);

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, resolveActiveContextAsync } from "./contexts.mjs";
import { resolveActiveContext } from "./contexts.mjs";
export const RETRY_DEFAULTS = Object.freeze({
maxAttempts: 3,
@@ -52,19 +52,6 @@ function resolveUrl(path, opts) {
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
}
/** The machine-derived token is valid only for the local loopback server. */
export function isLoopbackUrl(value) {
try {
const hostname = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (hostname === "localhost" || hostname === "::1") return true;
if (/^127(?:\.[0-9]{1,3}){3}$/.test(hostname)) return true;
if (/^::ffff:(?:127\.|7f[0-9a-f]{2}:)/i.test(hostname)) return true;
return false;
} catch {
return false;
}
}
export async function buildHeaders(opts) {
const headers = new Headers(opts.headers || {});
if (!headers.has("accept")) headers.set("accept", "application/json");
@@ -90,7 +77,7 @@ export async function buildHeaders(opts) {
let auth = explicitKey;
if (!auth) {
try {
const ctx = await resolveActiveContextAsync(opts.context ?? process.env.OMNIROUTE_CONTEXT);
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
auth = ctx?.accessToken || ctx?.apiKey || null;
} catch {
// No context credential available — fall through to the ambient fallback.
@@ -100,17 +87,10 @@ export async function buildHeaders(opts) {
if (auth && !headers.has("authorization")) {
headers.set("authorization", `Bearer ${auth}`);
}
// Inject the machine-derived credential only for an explicit local loopback
// destination. Remote contexts and absolute remote URLs use scoped access
// tokens and must never receive this machine-bound local credential.
const destinationUrl = opts.destinationUrl ?? getBaseUrl(opts);
if (!isLoopbackUrl(destinationUrl)) {
headers.delete(CLI_TOKEN_HEADER);
} else {
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
if (cliToken && !headers.has(CLI_TOKEN_HEADER)) {
headers.set(CLI_TOKEN_HEADER, cliToken);
}
// Inject machine-id derived CLI token; env var override for testing.
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
if (cliToken && !headers.has(CLI_TOKEN_HEADER)) {
headers.set(CLI_TOKEN_HEADER, cliToken);
}
if (opts.idempotencyKey && !headers.has("idempotency-key")) {
headers.set("idempotency-key", opts.idempotencyKey);
@@ -215,12 +195,8 @@ function fetchOnce(url, init, timeoutMs) {
export async function apiFetch(path, opts = {}) {
const method = String(opts.method || "GET").toUpperCase();
const url = resolveUrl(path, opts);
const headers = await buildHeaders({ ...opts, destinationUrl: url });
const headers = await buildHeaders(opts);
const body = serializeBody(opts.body, headers);
// Undici preserves custom headers across cross-origin redirects. A local server
// redirect must never turn the loopback machine credential into an outbound
// secret, so fail redirects whenever this header is present.
const redirect = headers.has(CLI_TOKEN_HEADER) ? "error" : opts.redirect;
const timeout =
opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000);
const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts);
@@ -229,7 +205,7 @@ export async function apiFetch(path, opts = {}) {
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetchOnce(url, { method, headers, body, redirect }, timeout);
const res = await fetchOnce(url, { method, headers, body }, timeout);
if (res.ok) return enrichResponse(res, opts);
if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) {
const delay = computeBackoff(attempt, res.headers.get("retry-after"));

View File

@@ -1,138 +0,0 @@
/**
* 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,7 +4,6 @@ import { withRuntime } from "../runtime.mjs";
import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { emit } from "../output.mjs";
import { resolveComboModels, collectModel } from "./comboModels.mjs";
const VALID_STRATEGIES = [
"priority",
@@ -126,31 +125,10 @@ export function registerCombo(program) {
.choices(VALID_STRATEGIES)
.default("priority")
)
.option(
"--models <spec>",
"Models for the combo: comma-separated provider/model entries, or a JSON array " +
'(e.g. --models "openai/gpt-4o,anthropic/claude-3-opus" or ' +
'--models \'[{"model":"gpt-4o","providerId":"openai"}]\')'
)
.option(
"--model <spec>",
"Add one model to the combo (provider/model or bare model id) — repeatable",
collectModel,
[]
)
.action(async (name, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
let models;
try {
models = resolveComboModels(opts);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
return;
}
const exitCode = await runComboCreateCommand(name, opts.strategy, {
...opts,
models,
output: globalOpts.output,
});
if (exitCode !== 0) process.exit(exitCode);
@@ -306,20 +284,12 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
return 1;
}
const models = Array.isArray(opts.models) ? opts.models : [];
if (!models.length) {
console.error(
"combo create requires at least one target. Pass --models <provider/model,...> and/or repeat --model <provider/model>."
);
return 1;
}
try {
return await withRuntime(async ({ kind, api, db }) => {
if (kind === "http") {
const res = await api("/api/combos", {
method: "POST",
body: { name, strategy, enabled: true, models, config: {} },
body: { name, strategy, enabled: true, models: [], config: {} },
retry: false,
acceptNotOk: true,
});
@@ -335,7 +305,7 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
console.error(`Combo '${name}' already exists. Delete it first.`);
return 1;
}
await db.combos.createCombo({ name, strategy, enabled: true, models, config: {} });
await db.combos.createCombo({ name, strategy, enabled: true, models: [], config: {} });
}
console.log(t("combo.created", { name }));

View File

@@ -1,142 +0,0 @@
// Parses the `--models` / `--model` options for `omniroute combo create` (#10954).
//
// Root cause of #10954: `combo create` only ever registered `--strategy`; the
// HTTP body (POST /api/combos) and the local-db fallback (db.combos.createCombo)
// both hardcoded `models: []`, so every combo created via the CLI came out
// empty regardless of what the operator intended to route to.
//
// Accepted shapes mirror the server-side Zod union in
// `src/shared/validation/schemas/combo.ts` (`comboModelEntry` /
// `createComboSchema.models`) so a CLI-built payload never gets rejected by
// the API that ultimately validates it:
// - a plain string ("provider/model" or a bare model id) — the server's
// `normalizeComboModels` (src/lib/combos/steps.ts) already splits the
// leading "provider/" segment off a plain string, so passing the raw
// token through is sufficient for the common case;
// - a structured `{ kind?: "model", model, providerId?, provider?, ... }`
// object;
// - a structured `{ kind: "combo-ref", comboName, ... }` object (nested
// combo reference).
//
// The CLI (bin/cli/**) ships as plain `.mjs` with relative-only imports — no
// `@/` path aliases and no TS transpilation at runtime — so importing the
// real Zod schema from `src/shared/validation/schemas/combo.ts` is not
// viable here. This module instead validates the same minimal shape by hand
// and stays a thin, independently testable unit.
/**
* Validates one already-parsed combo model entry against the shape accepted
* by `comboModelEntry` (string | model-step | combo-ref). Throws with a
* 1-based, human-readable position when the entry does not match.
*
* @param {unknown} entry
* @param {number} index
* @returns {string | Record<string, unknown>}
*/
export function validateComboModelEntryShape(entry, index) {
const position = index + 1;
if (typeof entry === "string") {
const trimmed = entry.trim();
if (trimmed.length === 0) {
throw new Error(`--models entry #${position}: empty model string`);
}
if (trimmed.length > 300) {
throw new Error(`--models entry #${position}: model string exceeds 300 characters`);
}
return trimmed;
}
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`--models entry #${position}: must be a string or a JSON object`);
}
const kind = entry.kind;
if (kind === "combo-ref") {
if (typeof entry.comboName !== "string" || entry.comboName.trim().length === 0) {
throw new Error(
`--models entry #${position}: kind "combo-ref" requires a non-empty "comboName"`
);
}
return entry;
}
if (kind !== undefined && kind !== "model") {
throw new Error(`--models entry #${position}: unknown "kind" value ${JSON.stringify(kind)}`);
}
if (typeof entry.model !== "string" || entry.model.trim().length === 0) {
throw new Error(`--models entry #${position}: requires a non-empty "model"`);
}
if (entry.providerId !== undefined && typeof entry.providerId !== "string") {
throw new Error(`--models entry #${position}: "providerId" must be a string`);
}
if (entry.provider !== undefined && typeof entry.provider !== "string") {
throw new Error(`--models entry #${position}: "provider" must be a string`);
}
return entry;
}
/**
* Parses one `--models` spec — either a JSON array (`--models '[{"model":"gpt-4o"}]'`)
* or a comma-separated list of provider/model tokens
* (`--models 'openai/gpt-4o,anthropic/claude-3-opus'`) — into an array of
* combo model entries.
*
* @param {string} spec
* @returns {Array<string | Record<string, unknown>>}
*/
export function parseModelsSpec(spec) {
const trimmed = String(spec ?? "").trim();
if (trimmed.length === 0) return [];
if (trimmed.startsWith("[")) {
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch (err) {
throw new Error(`--models: invalid JSON array (${err.message})`);
}
if (!Array.isArray(parsed)) {
throw new Error("--models: JSON value must be an array");
}
return parsed.map((entry, i) => validateComboModelEntryShape(entry, i));
}
return trimmed
.split(",")
.map((token) => token.trim())
.filter((token) => token.length > 0)
.map((token, i) => validateComboModelEntryShape(token, i));
}
/**
* Resolves the final `models` array for `combo create` from Commander opts:
* `--models <csv-or-json>` and/or repeatable `--model <spec>`.
*
* @param {{ models?: string, model?: string[] }} opts
* @returns {Array<string | Record<string, unknown>>}
*/
export function resolveComboModels(opts = {}) {
const result = [];
if (typeof opts.models === "string" && opts.models.trim().length > 0) {
result.push(...parseModelsSpec(opts.models));
}
if (Array.isArray(opts.model)) {
opts.model.forEach((token, i) => {
result.push(validateComboModelEntryShape(String(token).trim(), i));
});
}
return result;
}
/** Commander `collect`-style reducer for the repeatable `--model` option. */
export function collectModel(value, previous) {
previous.push(value);
return previous;
}

View File

@@ -4,12 +4,6 @@ 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
@@ -135,14 +129,6 @@ _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 \\
@@ -167,7 +153,7 @@ _omniroute() {
local -a providers
providers=($(_omniroute_get_cache providers))
_describe 'provider' providers ;;
*) _arguments '1:subcommand:(available list test test-all validate rotate status add import auth remove edit metrics metric)' ;;
*) _arguments '1:subcommand:(list add remove test)' ;;
esac ;;
chat|stream)
_arguments \\
@@ -179,12 +165,6 @@ _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
@@ -228,19 +208,15 @@ _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 connect contexts configure launch launch-codex run runtime repair"
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"
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 validate rotate status add import auth remove edit metrics metric" -- "\${cur}")); return 0 ;;
providers) COMPREPLY=($(compgen -W "available list test test-all" -- "\${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)
@@ -266,7 +242,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 connect contexts configure launch launch-codex update test run runtime repair
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
for cmd in $commands
complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd
@@ -275,14 +251,10 @@ 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 validate rotate status add import auth remove edit metrics metric'
complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all'
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

@@ -5,7 +5,6 @@ import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { resolveDataDir } from "../data-dir.mjs";
import { registerContexts } from "./contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function ensureBackup(configPath) {
if (!fs.existsSync(configPath)) return;
@@ -88,13 +87,6 @@ async function runConfigSetCommand(toolId, opts = {}) {
return 1;
}
const guard = await guardHostConfigTarget(result.configPath, {
toolLabel: toolId,
hostCommand: `omniroute config set ${toolId}`,
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
});
if (guard !== 0) return guard;
const nonInteractive = opts.nonInteractive || opts.yes;
if (!nonInteractive) {
@@ -279,10 +271,6 @@ export function registerConfig(program) {
.option("--model <model>", "Model identifier (where applicable)")
.option("--non-interactive", "Do not prompt for confirmation")
.option("--yes", "Skip confirmation prompt")
.option(
"--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
)
.action(async (tool, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runConfigSetCommand(tool, {
@@ -318,10 +306,6 @@ export function registerConfig(program) {
.option("--model <model>", "Model identifier")
.option("--non-interactive", "Do not prompt for confirmation")
.option("--yes", "Skip confirmation prompt")
.option(
"--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
)
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runConfigSetCommand("opencode", {

View File

@@ -2,17 +2,8 @@ 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
@@ -22,80 +13,11 @@ import { listManifestTargets, resolveManifestTarget } from "../cli-manifest.mjs"
* are in remote mode (`omniroute connect ...`) you pick from the remote server's
* live models and the profile is written on THIS machine.
*
* 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.
* v1 targets the Codex CLI (writes ~/.codex/<name>.config.toml). The credential
* is referenced by env var (OMNIROUTE_API_KEY) — never written to disk.
*/
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";
}
}
const SUPPORTED = ["codex"];
/** Derive a short, filesystem-safe profile name from a model id. */
export function profileNameFromModel(modelId) {
@@ -153,19 +75,6 @@ function buildCodexProfile(modelId, ctx) {
async function configureCodex(modelId, ctxWindow, opts) {
const codexHome = opts.codexHome || path.join(os.homedir(), ".codex");
const guard = await guardHostConfigTarget(codexHome, {
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`);
@@ -177,26 +86,19 @@ async function configureCodex(modelId, ctxWindow, opts) {
printInfo(`Use it: codex --profile ${profile}`);
printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block");
printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md).");
return 0;
}
export async function runConfigureCommand(cli, opts = {}, cmd) {
const target = resolveManifestTarget(cli, "configure");
if (!target) {
const target = String(cli || "").toLowerCase();
if (!SUPPORTED.includes(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(requestOpts);
models = await fetchModels(globalOpts);
} catch (e) {
printError(e instanceof Error ? e.message : String(e));
return 1;
@@ -212,15 +114,12 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
chosenId = `${opts.provider}/${chosenId}`;
}
if (!chosenId && !opts.yes) {
if (!chosenId) {
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 ${target} CLI`);
printHeading("Configure Codex CLI");
let providerList = providers;
if (opts.provider) {
providerList = providers.filter((p) => p === opts.provider);
@@ -229,21 +128,9 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
const p = await prompt.ask("Provider");
if (p) providerList = providers.filter((x) => x === p);
}
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 ? " …" : ""}`
);
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
const candidates = inProvider.length ? inProvider : ids;
printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`);
chosenId = await prompt.ask("Model id");
} finally {
prompt.close();
@@ -261,48 +148,10 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
}
const ctxWindow = contextWindowOf(entry);
let result;
if (target === "codex") {
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;
}
await configureCodex(chosenId, ctxWindow, opts);
}
if (result === 0 && !(opts.dryRun ?? opts["dry-run"])) {
recordModelPreference(target, chosenId, {
favorite: Boolean(opts.favorite),
unfavorite: Boolean(opts.unfavorite),
context: contextKey,
});
}
return result;
return 0;
}
function byId(models, id) {
@@ -318,24 +167,12 @@ export function registerConfigure(program) {
.command("configure <cli>")
.description(
t("configure.description") ||
"Pick a provider+model from the active server and configure a supported local CLI"
"Pick a provider+model from the active server and write a local CLI config (v1: codex)"
)
.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"
)
.action(async (cli, opts, cmd) => {
const code = await runConfigureCommand(cli, opts, cmd);
if (code !== 0) process.exit(code);

View File

@@ -1,5 +1,5 @@
import { apiFetch } from "../api.mjs";
import { loadContexts, saveContextsSecure } from "../contexts.mjs";
import { loadContexts, saveContexts } from "../contexts.mjs";
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
import { t } from "../i18n.mjs";
@@ -31,9 +31,7 @@ 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";
}
@@ -109,7 +107,7 @@ export async function runConnectCommand(host, opts = {}) {
description: `Remote OmniRoute (${host})`,
};
cfg.currentContext = name;
await saveContextsSecure(cfg);
saveContexts(cfg);
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
printInfo("All commands now target this server.");

View File

@@ -1,34 +1,21 @@
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
import {
loadContexts,
saveContextsSecure,
deleteContextCredential,
migrateContextCredentials,
resolveActiveContext,
} from "../contexts.mjs";
import { loadContexts, saveContexts, 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");
@@ -44,18 +31,6 @@ 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")
@@ -68,7 +43,7 @@ export function registerContexts(program) {
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const cfg = loadContexts();
const rows = Object.entries(contextMap(cfg)).map(([name, c]) => ({
const rows = Object.entries(cfg.contexts || {}).map(([name, c]) => ({
active: name === (cfg.currentContext || "default") ? "●" : "",
name,
baseUrl: c.baseUrl || "",
@@ -98,7 +73,7 @@ export function registerContexts(program) {
.option("--description <d>", "Context description")
.action(async (name, opts) => {
const cfg = loadContexts();
if (contextMap(cfg)[name]) {
if (cfg.contexts?.[name]) {
process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`);
process.exit(2);
}
@@ -111,29 +86,29 @@ export function registerContexts(program) {
if (opts.accessTokenStdin) accessToken = value;
else apiKey = value;
}
const contexts = contextMap(cfg);
contexts[name] = {
cfg.contexts = cfg.contexts || {};
cfg.contexts[name] = {
baseUrl: opts.url,
accessToken: accessToken || undefined,
apiKey,
scope: opts.scope || undefined,
description: opts.description || undefined,
};
await saveContextsSecure(cfg);
saveContexts(cfg);
process.stdout.write(`Added context '${name}'\n`);
});
ctx
.command("use <name>")
.description("Switch active context")
.action(async (name) => {
.action((name) => {
const cfg = loadContexts();
if (!contextMap(cfg)[name]) {
if (!cfg.contexts?.[name]) {
process.stderr.write(`No such context: ${name}\n`);
process.exit(2);
}
cfg.currentContext = name;
await saveContextsSecure(cfg);
saveContexts(cfg);
process.stdout.write(`Active context: ${name}\n`);
});
@@ -168,7 +143,7 @@ export function registerContexts(program) {
.action((name, opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const cfg = loadContexts();
const c = contextMap(cfg)[name];
const c = cfg.contexts?.[name];
if (!c) {
process.stderr.write(`No such context: ${name}\n`);
process.exit(2);
@@ -176,8 +151,6 @@ 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,
@@ -199,7 +172,7 @@ export function registerContexts(program) {
}
}
const cfg = loadContexts();
if (!contextMap(cfg)[name]) {
if (!cfg.contexts?.[name]) {
process.stderr.write(`No such context: ${name}\n`);
process.exit(2);
}
@@ -207,37 +180,29 @@ export function registerContexts(program) {
process.stderr.write("Cannot remove default context.\n");
process.exit(2);
}
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];
delete cfg.contexts[name];
if (cfg.currentContext === name) cfg.currentContext = "default";
await saveContextsSecure(cfg);
saveContexts(cfg);
process.stdout.write(`Removed context '${name}'\n`);
});
ctx
.command("rename <old> <new>")
.description("Rename a context")
.action(async (oldName, newName) => {
.action((oldName, newName) => {
const cfg = loadContexts();
const contexts = contextMap(cfg);
if (!contexts[oldName]) {
if (!cfg.contexts?.[oldName]) {
process.stderr.write(`No such context: ${oldName}\n`);
process.exit(2);
}
if (contexts[newName]) {
if (cfg.contexts[newName]) {
process.stderr.write(`Context '${newName}' already exists.\n`);
process.exit(2);
}
contexts[newName] = contexts[oldName];
delete contexts[oldName];
cfg.contexts[newName] = cfg.contexts[oldName];
delete cfg.contexts[oldName];
if (cfg.currentContext === oldName) cfg.currentContext = newName;
await saveContextsSecure(cfg);
saveContexts(cfg);
process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`);
});
@@ -248,7 +213,13 @@ export function registerContexts(program) {
.option("--no-secrets", "Omit API keys from export")
.action(async (opts, cmd) => {
const cfg = loadContexts();
const out = opts.noSecrets ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
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 json = JSON.stringify(out, null, 2);
if (opts.out) {
const { writeFileSync } = await import("node:fs");
@@ -277,12 +248,7 @@ export function registerContexts(program) {
const cfg = opts.merge
? loadContexts()
: { version: 1, currentContext: "default", contexts: {} };
if (!cfg.contexts && cfg.profiles) {
cfg.contexts = cfg.profiles;
delete cfg.profiles;
}
cfg.contexts = cfg.contexts || {};
const incoming = imported.contexts || imported.profiles || {};
const incoming = imported.contexts || {};
let count = 0;
for (const [name, raw] of Object.entries(incoming)) {
if (typeof name !== "string" || !name) continue;
@@ -299,38 +265,7 @@ export function registerContexts(program) {
if (!opts.merge && typeof imported.currentContext === "string") {
cfg.currentContext = imported.currentContext;
}
await saveContextsSecure(cfg);
saveContexts(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

@@ -4,9 +4,7 @@ import os from "node:os";
import path from "node:path";
import { createDecipheriv, scryptSync } from "node:crypto";
import { fileURLToPath, pathToFileURL } from "node:url";
import { isLoopbackUrl } from "../api.mjs";
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
import { getCliToken, CLI_TOKEN_HEADER } from "../utils/cliToken.mjs";
import { printHeading } from "../io.mjs";
import { t } from "../i18n.mjs";
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
@@ -380,11 +378,11 @@ function checkMemory() {
});
}
async function fetchWithTimeout(url, options = {}) {
async function fetchWithTimeout(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
try {
return await fetch(url, { ...options, signal: controller.signal });
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
@@ -473,98 +471,6 @@ async function checkServerLiveness(options = {}) {
);
}
export async function checkMachineTokenAuth(options = {}) {
if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") {
return warn("CLI machine token", "CLI machine-token authentication is disabled", {
derived: false,
accepted: false,
disabled: true,
tokenExposed: false,
});
}
let url;
try {
const parsed = new URL(resolveLivenessUrl(options));
if (
!["http:", "https:"].includes(parsed.protocol) ||
parsed.username ||
parsed.password ||
!isLoopbackUrl(parsed.toString())
) {
return warn(
"CLI machine token",
"Machine-token probes are limited to HTTP(S) loopback endpoints",
{ derived: false, accepted: false, tokenExposed: false }
);
}
parsed.pathname = "/api/cli/whoami";
parsed.search = "";
parsed.hash = "";
url = parsed.toString();
} catch {
return warn("CLI machine token", "Could not resolve the management endpoint", {
derived: false,
accepted: false,
tokenExposed: false,
});
}
const token = await getCliToken();
if (!token) {
return fail(
"CLI machine token",
"Could not derive a machine token; verify the node-machine-id runtime is installed",
{ derived: false, accepted: false, tokenExposed: false }
);
}
try {
const response = await fetchWithTimeout(url, {
headers: { [CLI_TOKEN_HEADER]: token },
redirect: "error",
});
if (response.ok) {
return ok("CLI machine token", "Server accepted the local machine token", {
url,
status: response.status,
derived: true,
accepted: true,
tokenExposed: false,
});
}
if (response.status === 401 || response.status === 403) {
return warn(
"CLI machine token",
"Server rejected the local machine token; if the CLI and server are on different hosts or container boundaries, run `omniroute connect <host> --key <oma_live_...>`",
{
url,
status: response.status,
derived: true,
accepted: false,
containerBoundaryLikely: true,
tokenExposed: false,
}
);
}
return warn("CLI machine token", `Machine-token probe returned HTTP ${response.status}`, {
url,
status: response.status,
derived: true,
accepted: false,
tokenExposed: false,
});
} catch {
return warn("CLI machine token", "Machine-token endpoint could not be reached", {
url,
status: 0,
derived: true,
accepted: false,
tokenExposed: false,
});
}
}
export async function collectDoctorChecks(context = {}, options = {}) {
const rootDir =
context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
@@ -582,7 +488,6 @@ export async function collectDoctorChecks(context = {}, options = {}) {
if (!options.skipLiveness) {
checks.push(await checkServerLiveness(options));
checks.push(await checkMachineTokenAuth(options));
}
// CLI tool health checks

View File

@@ -170,8 +170,8 @@ export function buildCodexEnv(baseEnv, authToken) {
* @param {string} baseUrl OmniRoute root URL (no /v1)
* @returns {string[]}
*/
export function buildCodexProviderArgs(baseUrl, model) {
const args = [
export function buildCodexProviderArgs(baseUrl) {
return [
"-c",
tomlAssign("model_provider", "omniroute"),
"-c",
@@ -185,15 +185,6 @@ export function buildCodexProviderArgs(baseUrl, model) {
"-c",
tomlAssign("model_providers.omniroute.requires_openai_auth", false),
];
if (model) {
const normalized = String(model).trim();
if (normalized) {
args.push("-c", tomlAssign("model_providers.omniroute.model", normalized));
}
}
return args;
}
/**
@@ -216,7 +207,7 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
// Provider injected via -c (works without config.toml); then the profile (model),
// then the user's pass-through args.
const providerArgs = buildCodexProviderArgs(baseUrl, opts.model);
const providerArgs = buildCodexProviderArgs(baseUrl);
const profileArgs = opts.profile ? ["--profile", opts.profile] : [];
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
const env = buildCodexEnv(process.env, authToken);
@@ -229,45 +220,18 @@ 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"
);
finish(127);
resolve(127);
} else {
console.error(String(err?.message || err));
finish(1);
resolve(1);
}
});
child.on("exit", (code, signalName) => {
finish(code ?? signalExitCode[signalName] ?? 0);
});
child.on("exit", (code) => resolve(code ?? 0));
});
}

View File

@@ -190,10 +190,7 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
const configDir = opts.profile
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
: undefined;
const env = buildClaudeEnv(process.env, baseUrl, authToken, {
configDir,
model: opts.model,
});
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
const { command, shell } = await resolveClaudeSpawn(process.platform);
@@ -204,43 +201,16 @@ 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.");
finish(127);
resolve(127);
} else {
console.error(String(err?.message || err));
finish(1);
resolve(1);
}
});
child.on("exit", (code, signalName) => {
finish(code ?? signalExitCode[signalName] ?? 0);
});
child.on("exit", (code) => resolve(code ?? 0));
});
}

View File

@@ -54,20 +54,11 @@ async function openBrowser(url) {
}
}
function targetApiOptions(opts = {}) {
return {
baseUrl: opts.baseUrl,
context: opts.context,
apiKey: opts.apiKey,
timeout: opts.timeout,
};
}
async function pollStatus(endpoint, timeoutMs, opts = {}) {
async function pollStatus(endpoint, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await sleep(2000);
const res = await apiFetch(endpoint, targetApiOptions(opts));
const res = await apiFetch(endpoint);
if (!res.ok) continue;
const data = await res.json();
if (data.status === "complete" || data.status === "completed") return data;
@@ -94,7 +85,7 @@ async function runBrowserFlow(def, opts) {
const authorizeUrl = `/api/oauth/${backendKey}/authorize${
redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""
}`;
const startRes = await apiFetch(authorizeUrl, { ...targetApiOptions(opts), method: "GET" });
const startRes = await apiFetch(authorizeUrl, { method: "GET" });
if (!startRes.ok) {
const detail = await safeErrorBody(startRes);
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`);
@@ -152,7 +143,6 @@ async function runBrowserFlow(def, opts) {
}
const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, {
...targetApiOptions(opts),
method: "POST",
body: {
code,
@@ -189,7 +179,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, { ...targetApiOptions(opts), method: "POST" });
const res = await apiFetch(endpoint, { method: "POST" });
if (!res.ok) {
process.stderr.write(`Import failed: ${res.status}\n`);
process.exit(1);
@@ -205,7 +195,6 @@ async function runSocialFlow(def, opts) {
process.exit(2);
}
const startRes = await apiFetch(`/api/oauth/${def.id}/social-authorize`, {
...targetApiOptions(opts),
method: "POST",
body: { social },
});
@@ -220,60 +209,36 @@ 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
opts.timeout ?? 300000
);
process.stdout.write(`Authorized: ${result.email ?? result.userId ?? "connected"}\n`);
}
async function runDeviceFlow(def, opts) {
const providerKey = resolveBackendKey(def.id);
let startRes = await apiFetch(`/api/oauth/${providerKey}/device-code`, targetApiOptions(opts));
if (!startRes.ok) {
startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
...targetApiOptions(opts),
method: "POST",
});
}
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" });
if (!startRes.ok) {
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
process.exit(1);
}
const start = await startRes.json();
const userCode = start.userCode ?? start.user_code ?? "";
const verificationUri =
start.verificationUriComplete ??
start.verification_uri_complete ??
start.verificationUri ??
start.verification_uri ??
start.authUrl ??
start.url ??
"";
if (userCode) {
process.stdout.write(`\nDevice code: ${userCode}\nVisit: ${verificationUri}\n\n`);
} else if (verificationUri) {
process.stdout.write(`\nVisit: ${verificationUri}\n\n`);
} else {
process.stdout.write(`\nAuthorization URL not available\n\n`);
}
if (opts.browser !== false && verificationUri)
await openBrowser(verificationUri);
process.stdout.write(
`\nDevice code: ${start.userCode ?? start.user_code ?? ""}\nVisit: ${start.verificationUri ?? start.verification_uri}\n\n`
);
if (opts.browser !== false)
await openBrowser(start.verificationUri ?? start.verification_uri ?? "");
process.stderr.write("Waiting for device authorization...\n");
const deadline = Date.now() + (opts.timeout ?? 300000);
const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
while (Date.now() < deadline) {
await sleep(intervalMs);
const statusRes = await apiFetch(
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`,
targetApiOptions(opts)
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`
);
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 },
});
@@ -290,7 +255,6 @@ 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(
@@ -311,23 +275,22 @@ export async function runOAuthStart(opts, cmd) {
}
export async function runOAuthStatus(opts, cmd) {
const globalOpts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts };
const globalOpts = cmd.optsWithGlobals();
const params = new URLSearchParams();
if (opts.provider) params.set("provider", opts.provider);
const res = await apiFetch(`/api/providers?${params}`, targetApiOptions(globalOpts));
const res = await apiFetch(`/api/providers?${params}`);
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
const connections = (data.connections ?? data.providers ?? data.items ?? data).filter(
const 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) `
@@ -340,11 +303,8 @@ export async function runOAuthRevoke(opts, cmd) {
}
const id = opts.connectionId;
const res = id
? await apiFetch(`/api/providers/${id}`, { ...targetApiOptions(opts), method: "DELETE" })
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, {
...targetApiOptions(opts),
method: "POST",
});
? await apiFetch(`/api/providers/${id}`, { method: "DELETE" })
: await apiFetch(`/api/oauth/${opts.provider}/revoke`, { method: "POST" });
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);

View File

@@ -1,166 +0,0 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { t } from "../i18n.mjs";
import { resolveDataDir } from "../data-dir.mjs";
import {
EXIT_CODES,
emit,
exitWith,
printError,
printInfo,
printSuccess,
printWarning,
} from "../output.mjs";
import { findPack } from "../../../scripts/packs/optionalPackManifest.mjs";
import {
findPackIndexFile,
installPack,
listPackStates,
packState,
packsRoot,
readPackIndex,
removePack,
} from "../../../scripts/packs/optionalPackInstaller.mjs";
const CLI_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
/**
* Locate + parse the bundle-shipped `optional-packs.index.json`.
* Search order: explicit --source dir, then walking up from the CLI module
* (bundle installs keep the index at the bundle root), then cwd.
*/
function loadIndex(sourceDir) {
const indexFile = findPackIndexFile([sourceDir, CLI_DIR, process.cwd()]);
if (!indexFile) return { indexFile: null, index: null };
return { indexFile, index: readPackIndex(indexFile) };
}
function stateRow(state, dataDir) {
return {
pack: state.name,
packVersion: state.packVersion,
installed: state.installed ? "yes" : "no",
verified: state.verified === null ? "-" : state.verified ? "ok" : "FAILED",
members: state.members.length,
installDir: path.join(packsRoot(dataDir), state.name),
errors: state.errors ?? [],
};
}
const STATE_SCHEMA = [
{ key: "pack", header: "pack" },
{ key: "packVersion", header: "packVersion" },
{ key: "installed", header: "installed" },
{ key: "verified", header: "verified" },
{ key: "members", header: "members" },
];
async function run(action) {
try {
await action();
} catch (err) {
exitWith(EXIT_CODES.ERROR, err instanceof Error ? err.message : String(err));
}
}
export function registerPacks(program) {
const packs = program.command("packs").description(t("packs.description"));
packs
.command("list")
.description(t("packs.listDescription"))
.option("--source <dir>", t("packs.sourceOpt"))
.action(async (opts) => {
await run(async () => {
const dataDir = resolveDataDir();
const { index } = loadIndex(opts.source);
emit(
(await listPackStates({ dataDir, index })).map((s) => stateRow(s, dataDir)),
opts,
STATE_SCHEMA
);
if (!index) printWarning(t("packs.warnNoIndex"));
});
});
packs
.command("install <name>")
.description(t("packs.installDescription"))
.option("--source <dir>", t("packs.sourceOpt"))
.action(async (name, opts) => {
await run(async () => {
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
const { indexFile, index } = loadIndex(opts.source);
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
const dataDir = resolveDataDir();
// The payload (tarball or extracted pack dir) lives next to the index
// unless the caller pointed elsewhere via --source.
await installPack(name, {
dataDir,
index,
sourceDir: opts.source || path.dirname(indexFile),
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
});
const installDir = path.join(packsRoot(dataDir), name);
printSuccess(t("packs.installed", { name, dir: installDir }));
printInfo(t("packs.restartHint"));
emit({ pack: name, installed: "yes", verified: "ok", installDir }, opts, STATE_SCHEMA);
});
});
packs
.command("verify [name]")
.description(t("packs.verifyDescription"))
.option("--source <dir>", t("packs.sourceOpt"))
.action(async (name, opts) => {
await run(async () => {
if (name && !findPack(name))
exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
const { index } = loadIndex(opts.source);
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
const dataDir = resolveDataDir();
const states = name
? [await packState(name, { dataDir, index })]
: await listPackStates({ dataDir, index });
emit(
states.map((s) => stateRow(s, dataDir)),
opts,
STATE_SCHEMA
);
const broken = states.filter((s) => s.installed && s.verified !== true);
if (broken.length > 0) {
for (const state of broken) {
for (const error of state.errors ?? []) printError(`${state.name}: ${error}`);
}
exitWith(EXIT_CODES.ERROR, t("packs.verifyFailed", { count: broken.length }));
}
if (!states.some((s) => s.installed)) {
printInfo(t("packs.noneInstalled"));
return;
}
printSuccess(t("packs.verifyOk"));
});
});
packs
.command("remove <name>")
.description(t("packs.removeDescription"))
.action(async (name, opts) => {
await run(async () => {
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
const dataDir = resolveDataDir();
const removed = removePack(name, {
dataDir,
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
});
if (removed) {
printSuccess(t("packs.removed", { name }));
printInfo(t("packs.restartHint"));
} else {
printInfo(t("packs.notInstalled", { name }));
}
emit({ pack: name, installed: removed ? "no" : "no" }, opts, STATE_SCHEMA);
});
});
}

View File

@@ -9,13 +9,10 @@ import { discoverPlugins } from "../plugins.mjs";
// (instead of string-interpolating into `execSync`) prevents a malicious plugin
// name like `foo; rm -rf ~` or `` foo`id` `` from being interpreted by the shell.
function runNpm(args) {
const isBun = Boolean(process.versions.bun);
const pm = isBun ? "bun" : "npm";
const cmdArgs = isBun && args[0] === "install" ? ["add", ...args.slice(1)] : args;
const res = spawnSync(pm, cmdArgs, { stdio: "inherit", shell: false });
const res = spawnSync("npm", args, { stdio: "inherit", shell: false });
if (res.error) throw res.error;
if (typeof res.status === "number" && res.status !== 0) {
throw new Error(`${pm} exited with code ${res.status}`);
throw new Error(`npm exited with code ${res.status}`);
}
}

View File

@@ -13,9 +13,6 @@ 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

@@ -1,498 +0,0 @@
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,7 +13,6 @@ 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 {
@@ -129,34 +128,7 @@ function buildTestInput(connection, apiKey) {
};
}
async function testProviderConnectionThroughServer(connection) {
try {
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}/test`, {
method: "POST",
body: {},
retry: false,
timeout: 30000,
acceptNotOk: true,
});
const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` };
return {
connection: publicConnection(connection),
...data,
valid: data.valid === true,
skipped: false,
};
} catch (error) {
return {
connection: publicConnection(connection),
valid: false,
skipped: false,
error: error instanceof Error ? error.message : String(error),
statusCode: null,
};
}
}
async function runProviderTest(db, connection, { serverUp = false } = {}) {
async function runProviderTest(db, connection) {
// Only API-key connections can be probed with a stored credential. OAuth /
// no-auth connections have nothing for testProviderApiKey() to send, and
// getProviderApiKey() throws for them by design — reporting that as a FAILED
@@ -178,9 +150,6 @@ async function runProviderTest(db, connection, { serverUp = false } = {}) {
// means the CLI has no probe recipe, not that the provider is unhealthy.
// Persisting it would overwrite a good test_status with a failure.
if (result.unsupported) {
if (serverUp) {
return testProviderConnectionThroughServer(connection);
}
return {
connection: publicConnection(connection),
...result,
@@ -296,7 +265,6 @@ export async function runTestCommand(selector, opts = {}) {
}
export async function runTestAllCommand(opts = {}) {
const serverUp = await isServerUp();
const { db } = await openOmniRouteDb();
try {
const connections = listProviderConnections(db);
@@ -311,7 +279,7 @@ export async function runTestAllCommand(opts = {}) {
});
continue;
}
results.push(await runProviderTest(db, connection, { serverUp }));
results.push(await runProviderTest(db, connection));
}
if (opts.json) {
@@ -636,8 +604,6 @@ 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) {
const quota = program
program
.command("quota")
.description(t("quota.description"))
.option("--provider <id>", "Filter by provider")
@@ -12,60 +12,6 @@ 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

@@ -60,7 +60,6 @@ import { registerAutostart } from "./autostart.mjs";
import { registerRepl } from "./repl.mjs";
import { registerLaunch } from "./launch.mjs";
import { registerLaunchCodex } from "./launch-codex.mjs";
import { registerRun } from "./run.mjs";
import { registerSetupCodex } from "./setup-codex.mjs";
import { registerSetupClaude } from "./setup-claude.mjs";
import { registerSetupOpencode } from "./setup-opencode.mjs";
@@ -80,7 +79,6 @@ import { registerConfigure } from "./configure.mjs";
import { registerApiCommands } from "../api-commands/registry.mjs";
import { registerPlugin } from "./plugin.mjs";
import { registerRadar } from "./radar.mjs";
import { registerPacks } from "./packs.mjs";
export function registerCommands(program) {
registerMemory(program);
@@ -146,7 +144,6 @@ export function registerCommands(program) {
registerRepl(program);
registerLaunch(program);
registerLaunchCodex(program);
registerRun(program);
registerSetupCodex(program);
registerSetupClaude(program);
registerSetupOpencode(program);
@@ -166,5 +163,4 @@ export function registerCommands(program) {
registerApiCommands(program);
registerPlugin(program);
registerRadar(program);
registerPacks(program);
}

View File

@@ -1,609 +0,0 @@
import {
runLaunchCommand as runLaunchClaudeCommand,
buildClaudeEnv,
resolveClaudeSpawn,
quoteClaudeArgs,
resolveLaunchTarget,
} from "./launch.mjs";
import {
buildCodexEnv,
buildCodexProviderArgs,
resolveCodexSpawn,
quoteCodexArgs,
resolveCodexTarget,
runLaunchCodexCommand as runLaunchCodexCommand,
} from "./launch-codex.mjs";
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";
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() === "";
}
function toAuthSource(targetOpts) {
const explicit =
!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";
} catch {
// no active context
}
if (!isBlank(process.env.OMNIROUTE_API_KEY)) return "env";
if (!isBlank(process.env.ANTHROPIC_AUTH_TOKEN)) return "env";
return "none";
}
/** 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) {
return resolveManifestTarget(target, "run");
}
export function listRunTargets() {
return listManifestTargets("run");
}
/**
* Normalize `--provider` + `--model` into one model id.
*
* - when model contains a slash, keep it as-is
* - when provider exists and model does not, prefix provider/
*/
export function resolveModelFromTargetOptions(targetOpts = {}) {
const provider = String(targetOpts.provider || "").trim();
const model = String(targetOpts.model || "").trim();
if (!model) return "";
if (provider && !model.includes("/")) return `${provider}/${model}`;
return model;
}
function describeCommand(command, shellMode) {
return `${command}${shellMode ? " [shell]" : ""}`;
}
function envPreview(before = {}, after = {}) {
const beforeKeys = new Set(Object.keys(before));
const changedOrAdded = [];
const removed = [];
for (const key of Object.keys(after)) {
if (!beforeKeys.has(key) || String(before[key]) !== String(after[key])) {
changedOrAdded.push(key);
}
}
for (const key of Object.keys(before)) {
if (!(key in after)) removed.push(key);
}
return {
changedOrAdded,
removed,
};
}
async function buildClaudePlan(rawOpts, args = []) {
const model = resolveModelFromTargetOptions(rawOpts);
const merged = {
...rawOpts,
model,
apiKey: resolveAuthTokenOption(rawOpts),
token: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile ?? rawOpts.p,
};
const { baseUrl, authToken } = resolveLaunchTarget(merged);
const commandSpec = await resolveClaudeSpawn(process.platform);
const configDir = merged.profile
? join(merged.claudeHome || join(os.homedir(), ".claude"), "profiles", merged.profile)
: undefined;
const env = buildClaudeEnv(process.env, baseUrl, authToken, {
configDir,
model: merged.model || undefined,
});
const quotedArgs = quoteClaudeArgs(args, process.platform);
return {
target: "claude",
baseUrl,
command: commandSpec.command,
shell: commandSpec.shell,
args: quotedArgs,
model: merged.model || undefined,
envDiff: envPreview(process.env, env),
authSource: toAuthSource(rawOpts),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
};
}
async function buildCodexPlan(rawOpts, args = []) {
const model = resolveModelFromTargetOptions(rawOpts);
const merged = {
...rawOpts,
apiKey: resolveAuthTokenOption(rawOpts),
model,
profile: rawOpts.profile ?? rawOpts.p,
};
const { baseUrl, authToken } = resolveCodexTarget(merged);
const commandSpec = await resolveCodexSpawn(process.platform);
const providerArgs = buildCodexProviderArgs(baseUrl, merged.model || undefined);
const profileArgs = merged.profile ? ["--profile", merged.profile] : [];
const env = buildCodexEnv(process.env, authToken);
const fullArgs = [...providerArgs, ...profileArgs, ...args];
const quotedArgs = quoteCodexArgs(fullArgs, process.platform);
return {
target: "codex",
baseUrl,
command: commandSpec.command,
shell: commandSpec.shell,
args: quotedArgs,
model: merged.model || undefined,
envDiff: envPreview(process.env, env),
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 '${target}'. Supported targets: ${listRunTargets().join(", ")}`
);
}
if (canonical === "claude") {
return buildClaudePlan(rawOpts, args);
}
if (canonical === "codex") {
return buildCodexPlan(rawOpts, args);
}
return buildGenericPlan(canonical, rawOpts, args);
}
function writeDryRunOutput(plan, opts = {}) {
const output = {
target: plan.target,
baseUrl: plan.baseUrl,
command: plan.command,
args: plan.args,
auth: {
source: plan.authSource,
present: plan.authSource !== "none",
},
shell: !!plan.shell,
model: plan.model || null,
configOverlay: plan.configOverlay || null,
env: {
changedOrAdded: plan.envDiff.changedOrAdded,
removed: plan.envDiff.removed,
},
};
if (opts.json) {
console.error(`Running in dry-run mode for '${plan.target}'.`);
console.log(JSON.stringify(output, null, 2));
} else {
console.log(`target: ${output.target}`);
console.log(`baseUrl: ${output.baseUrl}`);
console.log(`command: ${output.command}`);
console.log(`shell: ${output.shell ? "yes" : "no"}`);
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(", ")}`);
}
if (output.env.removed.length) {
console.log(`env removed: ${output.env.removed.join(", ")}`);
}
}
}
function buildExecutionOptionsForClaude(rawOpts) {
return {
...rawOpts,
model: resolveModelFromTargetOptions(rawOpts),
token: resolveAuthTokenOption(rawOpts),
apiKey: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile || rawOpts.p,
};
}
function buildExecutionOptionsForCodex(rawOpts) {
return {
...rawOpts,
model: resolveModelFromTargetOptions(rawOpts),
apiKey: resolveAuthTokenOption(rawOpts),
profile: rawOpts.profile || rawOpts.p,
};
}
/**
* Execute or preview one target launch.
*
* Return code conventions:
* 0 success, 1 runtime launch failure, 2 invalid args.
*/
export async function runCliTarget(target, opts = {}, args = []) {
const canonical = resolveRunTarget(target);
if (!canonical) {
process.stderr.write(
`Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}\n`
);
return 2;
}
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);
return 0;
}
if (canonical === "claude") {
return await runLaunchClaudeCommand(buildExecutionOptionsForClaude(opts), args);
}
if (canonical === "codex") {
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args);
}
return await runGenericTarget(canonical, opts, args);
}
export function registerRun(program) {
program
.command("run <target>")
.description(t("run.description") || "Run a supported CLI target through OmniRoute")
.option(
"--port <port>",
"Local OmniRoute port (ignored when --remote or --base-url is set)",
"20128"
)
.option(
"--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)
.allowExcessArguments(true)
.argument("[toolArgs...]")
.action(async (target, toolArgs = [], opts, cmd) => {
const globalOpts = cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {};
const merged = { ...globalOpts, ...opts };
const code = await runCliTarget(target, merged, toolArgs);
// process.exit() here can interrupt cleanup when the child terminates;
// setting process.exitCode lets the event loop drain first.
process.exitCode = code;
});
}

View File

@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { platform, totalmem } from "node:os";
import { platform, totalmem, hostname as osHostname } from "node:os";
import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
@@ -12,7 +12,6 @@ import {
isFatalInstrumentationHookFailure,
formatAndroidInstrumentationFailureHint,
} from "../utils/ensureAndroidCacheDir.mjs";
import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs";
import {
resolveMaxOldSpaceMb,
calibrateHeapFallbackMb,
@@ -132,15 +131,6 @@ export async function runServe(opts = {}) {
`);
}
// GHSA-wmgv-ph3p-rv57: the default posture (all interfaces + no API key) is a
// deliberate local-first choice, but it must be loud at startup — an operator
// on an untrusted network learns the two escape hatches here, not after a
// surprise quota bill.
const exposureWarning = resolveExposureWarning();
if (exposureWarning) {
console.warn(`\x1b[33m ⚠ ${exposureWarning}\x1b[0m\n`);
}
const serverWsJs = join(APP_DIR, "server-ws.mjs");
const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js");
@@ -217,10 +207,16 @@ export async function runServe(opts = {}) {
PORT: String(dashboardPort),
DASHBOARD_PORT: String(dashboardPort),
API_PORT: String(apiPort),
// #10492: HOSTNAME is standard shell state on Unix-like systems, not an
// OmniRoute bind setting. The resolver only keeps its legacy meaning on
// Windows; OMNIROUTE_SERVER_HOST is the cross-platform explicit setting.
HOSTNAME: resolveServerHost(),
// #6194: POSIX shells (bash/zsh) auto-set HOSTNAME to the machine name — the
// .env loader (first-wins) can never override it. Ignore HOSTNAME when it
// matches the OS-reported hostname (the auto-set signature). OMNIROUTE_SERVER_HOST
// takes precedence; legacy HOSTNAME values that don't match os.hostname() are
// still honoured for backward compatibility (e.g. Windows CMD/PowerShell users
// who set HOSTNAME in .env where it is NOT auto-set).
HOSTNAME:
process.env.OMNIROUTE_SERVER_HOST ||
(process.env.HOSTNAME !== osHostname() ? process.env.HOSTNAME : undefined) ||
"0.0.0.0",
NODE_ENV: "production",
// #5238: preserve a user-set NODE_OPTIONS (incl. their own
// `--max-old-space-size=…`) instead of clobbering it with the calibrated

View File

@@ -13,7 +13,6 @@ import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, "");
@@ -26,9 +25,7 @@ export function resolveAiderTarget(opts = {}) {
if (opts.remote) root = stripToRoot(opts.remote);
else {
try {
root = stripToRoot(
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
} catch {
/* none */
}
@@ -81,7 +78,7 @@ async function fetchModelIds(apiBase, apiKey) {
const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return [];
const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch {
return [];
@@ -91,16 +88,7 @@ async function fetchModelIds(apiBase, apiKey) {
export async function runSetupAiderCommand(opts = {}) {
const { apiBase, apiKey } = resolveAiderTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Aider",
hostCommand: "omniroute setup-aider",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml");
printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)");
printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`);
@@ -119,9 +107,7 @@ export async function runSetupAiderCommand(opts = {}) {
}
}
if (!model) {
printError(
"A model is required. Pass --model <id> (the openai/ prefix is added automatically)."
);
printError("A model is required. Pass --model <id> (the openai/ prefix is added automatically).");
return 2;
}
@@ -153,10 +139,6 @@ export function registerSetupAider(program) {
.option("--config-path <path>", ".aider.conf.yml path (default: ~/.aider.conf.yml)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupAiderCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -20,7 +20,6 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import {
categoriseModel,
isCodexCompatibleTextModel,
@@ -148,14 +147,6 @@ export async function runSetupClaudeCommand(opts = {}) {
printHeading("OmniRoute → Claude Code profile generator");
printInfo(`Connecting to ${baseUrl}`);
const guard = await guardHostConfigTarget(profilesRoot, {
toolLabel: "Claude Code",
hostCommand: "omniroute setup-claude",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
// ── Fetch model catalog ───────────────────────────────────────────────────
let models;
try {
@@ -229,10 +220,6 @@ export function registerSetupClaude(program) {
"Comma-separated substrings — only matching model IDs (e.g. glm,kimi)"
)
.option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const exitCode = await runSetupClaudeCommand(opts);
if (exitCode !== 0) process.exit(exitCode);

View File

@@ -16,7 +16,6 @@ import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) {
let s = String(url || "").replace(/\/+$/, "");
@@ -29,14 +28,11 @@ export function resolveClineTarget(opts = {}) {
if (opts.remote) baseUrl = stripToRoot(opts.remote);
else {
try {
baseUrl = stripToRoot(
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
baseUrl = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
} catch {
/* none */
}
if (!baseUrl)
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
}
let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) {
@@ -85,7 +81,7 @@ async function fetchModelIds(baseUrl, apiKey) {
const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return [];
const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch {
return [];
@@ -97,14 +93,6 @@ export async function runSetupClineCommand(opts = {}) {
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const clineDir = opts.clineDir ?? opts["cline-dir"] ?? join(os.homedir(), ".cline", "data");
const guard = await guardHostConfigTarget(clineDir, {
toolLabel: "Cline",
hostCommand: "omniroute setup-cline",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Cline (OpenAI-compatible)");
printInfo(`Server: ${baseUrl}`);
@@ -134,18 +122,7 @@ export async function runSetupClineCommand(opts = {}) {
if (dryRun) {
console.log(`\n── [dry-run] ${gsPath} ──`);
console.log(
JSON.stringify(
{
actModeApiProvider: globalState.actModeApiProvider,
planModeApiProvider: globalState.planModeApiProvider,
openAiBaseUrl: globalState.openAiBaseUrl,
openAiModelId: globalState.openAiModelId,
},
null,
2
)
);
console.log(JSON.stringify({ actModeApiProvider: globalState.actModeApiProvider, planModeApiProvider: globalState.planModeApiProvider, openAiBaseUrl: globalState.openAiBaseUrl, openAiModelId: globalState.openAiModelId }, null, 2));
console.log(`\n── [dry-run] ${secPath} ── (openAiApiKey: ${apiKey ? "set" : "sk_omniroute"})`);
} else {
if (!existsSync(clineDir)) mkdirSync(clineDir, { recursive: true });
@@ -156,9 +133,7 @@ export async function runSetupClineCommand(opts = {}) {
}
// The VS Code extension uses opaque globalStorage — can't be file-written.
printInfo(
"\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):"
);
printInfo("\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):");
printInfo(` Base URL: ${baseUrl} (NOT /v1 — Cline appends it)`);
printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
printInfo(` Model: ${model}`);
@@ -178,10 +153,6 @@ export function registerSetupCline(program) {
.option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupClineCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -16,7 +16,6 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import { t } from "../i18n.mjs";
// ── Model categorisation ──────────────────────────────────────────────────────
@@ -307,14 +306,6 @@ export async function runSetupCodexCommand(opts = {}) {
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
printHeading(`OmniRoute → Codex CLI profile generator`);
const guard = await guardHostConfigTarget(codexHome, {
toolLabel: "Codex",
hostCommand: "omniroute setup-codex",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printInfo(`Connecting to ${baseUrl}`);
// ── Fetch model catalog ───────────────────────────────────────────────────
@@ -389,10 +380,6 @@ export function registerSetupCodex(program) {
"Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)"
)
.option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const exitCode = await runSetupCodexCommand(opts);
if (exitCode !== 0) process.exit(exitCode);

View File

@@ -14,7 +14,6 @@ import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { categoriseModel } from "./setup-codex.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const SECRET_REF = "${{ secrets.OMNIROUTE_API_KEY }}";
@@ -93,7 +92,7 @@ async function fetchModelIds(apiBase, apiKey) {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch (e) {
throw new Error(`Could not fetch models: ${e.message}`);
@@ -103,22 +102,8 @@ async function fetchModelIds(apiBase, apiKey) {
export async function runSetupContinueCommand(opts = {}) {
const { apiBase, apiKey } = resolveContinueTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const only = opts.only
? opts.only
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Continue",
hostCommand: "omniroute setup-continue",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml");
printHeading("OmniRoute → Continue (config.yaml)");
printInfo(`apiBase: ${apiBase}`);
@@ -165,7 +150,7 @@ export async function runSetupContinueCommand(opts = {}) {
printInfo("\nProvide the key (config.yaml references it, not stores it):");
printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)");
printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env");
printInfo('Run: cn -p "reply OK"');
printInfo("Run: cn -p \"reply OK\"");
return 0;
}
@@ -181,10 +166,6 @@ export function registerSetupContinue(program) {
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.option("--config-path <path>", "config.yaml path (default: ~/.continue/config.yaml)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupContinueCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -13,7 +13,6 @@ import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { categoriseModel } from "./setup-codex.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const API_KEY_REF = "$OMNIROUTE_API_KEY";
@@ -88,29 +87,15 @@ async function fetchModelIds(baseUrl, apiKey) {
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
}
export async function runSetupCrushCommand(opts = {}) {
const { baseUrl, apiKey } = resolveCrushTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const only = opts.only
? opts.only
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Crush",
hostCommand: "omniroute setup-crush",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json");
printHeading("OmniRoute → Crush (openai-compat)");
printInfo(`base_url: ${baseUrl}`);
@@ -135,17 +120,13 @@ export async function runSetupCrushCommand(opts = {}) {
if (dryRun) {
console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out));
printInfo(
`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`
);
printInfo(`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`);
return 0;
}
mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, out, "utf8");
printSuccess(`Wrote ${configPath} (${provider.models.length} models under providers.omniroute)`);
printInfo(
"Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."
);
printInfo("Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=...");
printInfo("Then run: crush");
return 0;
}
@@ -160,10 +141,6 @@ export function registerSetupCrush(program) {
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.option("--config-path <path>", "crush.json path (default: ~/.config/crush/crush.json)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupCrushCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -10,7 +10,6 @@
import { printHeading, printInfo, printSuccess } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { isContainerRuntime } from "../utils/config-home-guard.mjs";
function ensureV1(url) {
const s = String(url || "").replace(/\/+$/, "");
@@ -72,7 +71,7 @@ async function fetchModelIds(apiBase, apiKey) {
});
if (!res.ok) return [];
const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch {
return [];
@@ -85,32 +84,19 @@ export async function runSetupCursorCommand(opts = {}) {
printInfo(`Server: ${apiBase}`);
let models = [];
const only = opts.only
? opts.only
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
const ids = await fetchModelIds(apiBase, apiKey);
models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids;
console.log("\n" + buildCursorInstructions({ apiBase, models }));
printSuccess("\nCursor is configured manually (no file written — Cursor's storage is opaque).");
if (await isContainerRuntime()) {
printInfo(
"Note: this ran inside a container, so the base URL above is the container's own view. " +
"Use the address the host reaches OmniRoute on (e.g. the published port) in Cursor's settings."
);
}
return 0;
}
export function registerSetupCursor(program) {
program
.command("setup-cursor")
.description(
"Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)"
)
.description("Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)")
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")

View File

@@ -14,7 +14,6 @@ import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, "");
@@ -27,9 +26,7 @@ export function resolveGooseTarget(opts = {}) {
if (opts.remote) root = stripToRoot(opts.remote);
else {
try {
root = stripToRoot(
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
} catch {
/* none */
}
@@ -83,7 +80,7 @@ async function fetchModelIds(host, apiKey) {
const res = await fetch(`${host}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return [];
const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch {
return [];
@@ -93,16 +90,7 @@ async function fetchModelIds(host, apiKey) {
export async function runSetupGooseCommand(opts = {}) {
const { host, apiKey } = resolveGooseTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Goose",
hostCommand: "omniroute setup-goose",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
printHeading("OmniRoute → Goose (openai-compatible)");
printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`);
@@ -140,16 +128,14 @@ export async function runSetupGooseCommand(opts = {}) {
printInfo("\nProvide the key (Goose reads it from the env / OS keyring):");
console.log(buildGooseEnvRecipe({ host, model }));
printInfo('Then run: goose session (or: goose run -t "reply OK")');
printInfo("Then run: goose session (or: goose run -t \"reply OK\")");
return 0;
}
export function registerSetupGoose(program) {
program
.command("setup-goose")
.description(
"Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe"
)
.description("Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe")
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
@@ -157,10 +143,6 @@ export function registerSetupGoose(program) {
.option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupGooseCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -14,7 +14,6 @@ import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
/** Ensure the URL ends with /v1 (Kilo appends /chat/completions to it). */
function ensureV1(url) {
@@ -62,11 +61,7 @@ export function buildKiloAuth(existing, { apiKey, baseUrl, model }) {
/** Merge the kilocode.* keys into VS Code settings.json (extension surface). */
export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) {
const s = { ...(existing || {}) };
s["kilocode.customProvider"] = {
name: "OmniRoute",
baseURL: baseUrl,
apiKey: apiKey || "sk_omniroute",
};
s["kilocode.customProvider"] = { name: "OmniRoute", baseURL: baseUrl, apiKey: apiKey || "sk_omniroute" };
s["kilocode.defaultModel"] = model;
return s;
}
@@ -90,7 +85,7 @@ async function fetchModelIds(root, apiKey) {
});
if (!res.ok) return [];
const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch {
return [];
@@ -100,22 +95,9 @@ async function fetchModelIds(root, apiKey) {
export async function runSetupKiloCommand(opts = {}) {
const { baseUrl, apiKey } = resolveKiloTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const authPath =
opts.authPath ??
opts["auth-path"] ??
join(os.homedir(), ".local", "share", "kilo", "auth.json");
const guard = await guardHostConfigTarget(authPath, {
toolLabel: "Kilo Code",
hostCommand: "omniroute setup-kilo",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const authPath = opts.authPath ?? opts["auth-path"] ?? join(os.homedir(), ".local", "share", "kilo", "auth.json");
const vscodePath =
opts.vscodeSettings ??
opts["vscode-settings"] ??
join(os.homedir(), ".config", "Code", "User", "settings.json");
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json");
printHeading("OmniRoute → Kilo Code (OpenAI-compatible)");
printInfo(`Server: ${baseUrl}`);
@@ -134,9 +116,7 @@ export async function runSetupKiloCommand(opts = {}) {
}
}
if (!model) {
printError(
"A model is required. Pass --model <id> (Kilo's extension has no model auto-discovery)."
);
printError("A model is required. Pass --model <id> (Kilo's extension has no model auto-discovery).");
return 2;
}
@@ -152,19 +132,12 @@ export async function runSetupKiloCommand(opts = {}) {
console.log(`\n── [dry-run] ${authPath} ──`);
console.log(
JSON.stringify(
{
"openai-compatible": {
...auth["openai-compatible"],
apiKey: apiKey ? "set" : "sk_omniroute",
},
},
{ "openai-compatible": { ...auth["openai-compatible"], apiKey: apiKey ? "set" : "sk_omniroute" } },
null,
2
)
);
console.log(
`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`
);
console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`);
} else {
mkdirSync(join(authPath, ".."), { recursive: true });
writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n", "utf8");
@@ -194,20 +167,10 @@ export function registerSetupKilo(program) {
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id for Kilo (required unless picked interactively)")
.option(
"--auth-path <path>",
"Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)"
)
.option(
"--vscode-settings <path>",
"VS Code settings.json (default: ~/.config/Code/User/settings.json)"
)
.option("--auth-path <path>", "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)")
.option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupKiloCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -30,7 +30,6 @@ import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -317,13 +316,6 @@ export async function runSetupOpenCodeCommand(opts = {}) {
printInfo(`OpenCode config dir: ${opencodeConfigDir}`);
printInfo(`OpenCode data dir: ${opencodeDataDir}`);
const guard = await guardHostConfigTarget(opencodeConfigDir, {
toolLabel: "OpenCode",
hostCommand: "omniroute setup opencode",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
});
if (guard !== 0) return { exitCode: guard };
// 1. Resolve bundled plugin
let pluginInfo;
try {
@@ -428,10 +420,6 @@ export function registerSetupOpenCode(setupCommand) {
false
)
.option("--non-interactive", "Do not prompt; skip the auth login step", false)
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts, cmd) => {
// The parent `setup` command uses cmd.optsWithGlobals(); we mirror
// that here so global flags (--json, --base-url, --api-key) still

View File

@@ -14,7 +14,6 @@ import { basename, dirname } from "node:path";
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}";
const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 };
@@ -120,15 +119,6 @@ export async function runSetupOpencodeCommand(opts = {}) {
const { resolveOpencodeConfigPath } =
await import("../../../src/shared/services/opencodeConfigPath.ts");
configPath = resolveOpencodeConfigPath();
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "OpenCode",
hostCommand: "omniroute setup-opencode",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
raw = await generateOpencodeConfig({
baseUrl,
apiKey,
@@ -173,10 +163,6 @@ export function registerSetupOpencode(program) {
.option("--model <id>", "Set the default top-level model (omniroute/<id>)")
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupOpencodeCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -18,7 +18,6 @@ import {
normalizeQwenCodeBaseUrl,
} from "../../../src/shared/services/qwenCodeConfig.ts";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import { createPrompt, printError, printHeading, printInfo, printSuccess } from "../io.mjs";
/** Resolve base URL and key from flags, active context, then local defaults. */
@@ -103,16 +102,6 @@ export async function runSetupQwenCommand(opts = {}) {
printHeading("OmniRoute → Qwen Code (OpenAI-compatible)");
printInfo(`baseUrl: ${baseUrl}`);
for (const target of [settingsPath, envPath]) {
const guard = await guardHostConfigTarget(target, {
toolLabel: "Qwen Code",
hostCommand: "omniroute setup-qwen",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
}
let model = String(opts.model || "").trim();
if (!model && !opts.yes) {
const modelIds = await fetchModelIds(baseUrl, apiKey);
@@ -170,10 +159,6 @@ export function registerSetupQwen(program) {
.option("--env-path <path>", "Qwen Code .env path")
.option("--yes", "Non-interactive; requires --model")
.option("--dry-run", "Print settings without writing files or secrets")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupQwenCommand(opts);
if (code !== 0) process.exitCode = code;

View File

@@ -16,7 +16,6 @@ import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function ensureV1(url) {
const s = String(url || "").replace(/\/+$/, "");
@@ -90,7 +89,7 @@ async function fetchModelIds(baseUrl, apiKey) {
});
if (!res.ok) return [];
const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch {
return [];
@@ -100,20 +99,9 @@ async function fetchModelIds(baseUrl, apiKey) {
export async function runSetupRooCommand(opts = {}) {
const { baseUrl, apiKey } = resolveRooTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const importPath =
opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json");
const guard = await guardHostConfigTarget(importPath, {
toolLabel: "Roo Code",
hostCommand: "omniroute setup-roo",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const importPath = opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json");
const vscodePath =
opts.vscodeSettings ??
opts["vscode-settings"] ??
join(os.homedir(), ".config", "Code", "User", "settings.json");
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json");
printHeading("OmniRoute → Roo Code (OpenAI-compatible)");
printInfo(`Server: ${baseUrl}`);
@@ -142,27 +130,8 @@ export async function runSetupRooCommand(opts = {}) {
if (dryRun) {
console.log(`\n── [dry-run] ${importPath} ──`);
console.log(
JSON.stringify(
{
...importDoc,
providerProfiles: {
...importDoc.providerProfiles,
apiConfigs: {
OmniRoute: {
...importDoc.providerProfiles.apiConfigs.OmniRoute,
openAiApiKey: apiKey ? "set" : "sk_omniroute",
},
},
},
},
null,
2
)
);
console.log(
`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`
);
console.log(JSON.stringify({ ...importDoc, providerProfiles: { ...importDoc.providerProfiles, apiConfigs: { OmniRoute: { ...importDoc.providerProfiles.apiConfigs.OmniRoute, openAiApiKey: apiKey ? "set" : "sk_omniroute" } } } }, null, 2));
console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`);
} else {
mkdirSync(join(importPath, ".."), { recursive: true });
writeFileSync(importPath, JSON.stringify(importDoc, null, 2) + "\n", "utf8");
@@ -192,20 +161,10 @@ export function registerSetupRoo(program) {
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id for Roo (required unless picked interactively)")
.option(
"--import-path <path>",
"Roo import JSON path (default: ~/.omniroute/roo-settings.json)"
)
.option(
"--vscode-settings <path>",
"VS Code settings.json (default: ~/.config/Code/User/settings.json)"
)
.option("--import-path <path>", "Roo import JSON path (default: ~/.omniroute/roo-settings.json)")
.option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => {
const code = await runSetupRooCommand(opts);
if (code !== 0) process.exit(code);

View File

@@ -133,29 +133,6 @@ 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")
@@ -172,7 +149,7 @@ export function registerSetup(program) {
.option("--list", "List all supported CLI tools")
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const exitCode = await runSetupCommand(mergeSetupOptions(opts, globalOpts));
const exitCode = await runSetupCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});

View File

@@ -38,19 +38,12 @@ export async function runTestProviderCommand(provider, model, opts = {}) {
}
const targetProvider = provider || "anthropic";
const connections = await _loadConnections();
if (!connections) return 1;
const connection = _resolveConnection(connections, targetProvider, model);
if (!connection) {
console.error(`Provider connection not found: ${targetProvider}`);
return 1;
}
const targetModel = model || connection.defaultModel;
const targetModel = model || "claude-haiku-4-5-20251001";
const repeat = opts.repeat && opts.repeat > 0 ? opts.repeat : 1;
const results = [];
for (let i = 0; i < repeat; i++) {
const result = await _runSingleTest(connection, targetModel);
const result = await _runSingleTest(targetProvider, targetModel);
results.push(result);
}
@@ -77,10 +70,18 @@ export async function runTestProviderCommand(provider, model, opts = {}) {
}
async function _runAllProviders(opts) {
const loaded = await _loadConnections();
if (!loaded) return 1;
const connections = loaded.filter(
(c) => c.isActive !== false && (c.authType === "apikey" || c.testStatus !== "unavailable")
const res = await apiFetch("/api/providers?limit=200", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("test.noServer"));
return 1;
}
const data = await res.json();
const connections = (data.providers ?? data.items ?? data).filter(
(c) => c.authType === "apikey" || c.testStatus !== "unavailable"
);
if (connections.length === 0) {
console.log(t("test.noProviders"));
@@ -88,7 +89,6 @@ async function _runAllProviders(opts) {
}
const providers = connections.map((c) => ({
connectionId: c.id,
provider: c.provider ?? c.id,
model: c.defaultModel ?? c.model,
}));
@@ -102,8 +102,8 @@ async function _runAllProviders(opts) {
}
const results = await Promise.all(
providers.map(async ({ connectionId, provider, model }) => {
const r = await _runSingleTest({ id: connectionId }, model);
providers.map(async ({ provider, model }) => {
const r = await _runSingleTest(provider, model);
return { provider, model, ...r };
})
);
@@ -123,13 +123,6 @@ async function _runAllProviders(opts) {
async function _runCompare(provider, opts) {
const targetProvider = provider || "anthropic";
const connections = await _loadConnections();
if (!connections) return 1;
const connection = _resolveConnection(connections, targetProvider);
if (!connection) {
console.error(`Provider connection not found: ${targetProvider}`);
return 1;
}
const models = opts.compare
.split(",")
.map((m) => m.trim())
@@ -145,7 +138,7 @@ async function _runCompare(provider, opts) {
for (const model of models) {
const results = [];
for (let i = 0; i < repeat; i++) {
const result = await _runSingleTest(connection, model);
const result = await _runSingleTest(targetProvider, model);
results.push(result);
}
rows.push({ model, ..._aggregate(results, true) });
@@ -187,55 +180,19 @@ async function _runCompare(provider, opts) {
return rows.every((r) => r.success) ? 0 : 1;
}
async function _loadConnections() {
const res = await apiFetch("/api/providers?limit=200", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (!res.ok) {
console.error(t("test.noServer"));
return null;
}
const data = await res.json();
const connections = data.connections ?? data.providers ?? data.items ?? data;
if (!Array.isArray(connections)) {
console.error(t("test.noServer"));
return null;
}
return connections;
}
function _resolveConnection(connections, selector, model) {
const normalized = String(selector || "")
.trim()
.toLowerCase();
const active = connections.filter((connection) => connection.isActive !== false);
return (
active.find((connection) => String(connection.id || "").toLowerCase() === normalized) ??
active.find((connection) => String(connection.name || "").toLowerCase() === normalized) ??
active.find(
(connection) =>
String(connection.provider || "").toLowerCase() === normalized &&
(!model || connection.defaultModel === model || connection.model === model)
) ??
active.find((connection) => String(connection.provider || "").toLowerCase() === normalized)
);
}
async function _runSingleTest(connection, model) {
async function _runSingleTest(provider, model) {
const startMs = Date.now();
try {
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}/test`, {
const res = await apiFetch("/api/v1/providers/test", {
method: "POST",
body: model ? { validationModelId: model } : {},
body: { provider, model },
retry: false,
timeout: 30000,
acceptNotOk: true,
});
const durationMs = Date.now() - startMs;
const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` };
return { ...data, success: data.valid === true, durationMs };
const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` };
return { ...data, durationMs };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {

View File

@@ -3,108 +3,6 @@ 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");
@@ -121,13 +19,14 @@ function defaultConfig() {
}
export function loadContexts() {
return readConfigFile();
try {
if (!existsSync(configPath())) return defaultConfig();
return JSON.parse(readFileSync(configPath(), "utf8"));
} catch {
return defaultConfig();
}
}
/**
* 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 });
@@ -137,116 +36,6 @@ 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.
*
@@ -265,13 +54,7 @@ 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 applyCachedCredential(found);
if (found) return 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

@@ -1287,9 +1287,6 @@
"notRunning": "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.",
"notFound": "The 'claude' CLI was not found in PATH."
},
"run": {
"description": "Launch a supported CLI target through OmniRoute"
},
"setupClaude": {
"description": "Generate ~/.claude/profiles Claude Code profiles from the OmniRoute model catalog"
},
@@ -1300,30 +1297,12 @@
"description": "Manage scoped CLI access tokens (remote mode)"
},
"configure": {
"description": "Pick a provider+model from the active server and configure a supported local CLI"
"description": "Pick a provider+model from the active server and write a local CLI config"
},
"launchCodex": {
"description": "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
},
"setupCodex": {
"description": "Generate ~/.codex profile files from OmniRoute live model catalog"
},
"packs": {
"description": "Manage optional runtime packs (ML / browser automation)",
"listDescription": "List optional packs and their install state",
"installDescription": "Install an optional pack into DATA_DIR",
"verifyDescription": "Verify installed packs against the shipped checksum index",
"removeDescription": "Remove an installed optional pack",
"sourceOpt": "Directory holding pack payloads and the pack index",
"warnNoIndex": "optional-packs.index.json not found — install/verify are unavailable in this checkout (desktop bundles ship it)",
"errUnknown": "unknown pack: {name}",
"errNoIndex": "pack index not found; pass --source <dir> holding the pack payload (desktop bundles ship it next to the app)",
"installed": "pack \"{name}\" installed and verified at {dir}",
"restartHint": "restart the OmniRoute server (or desktop app) so the runtime picks the pack up",
"removed": "pack \"{name}\" removed",
"notInstalled": "pack \"{name}\" was not installed",
"verifyOk": "all installed packs verified",
"verifyFailed": "{count} pack(s) failed verification",
"noneInstalled": "no optional packs installed"
}
}

View File

@@ -1284,9 +1284,6 @@
"notRunning": "OmniRoute não está acessível em {port}. Inicie com 'omniroute serve'.",
"notFound": "O CLI 'claude' não foi encontrado no PATH."
},
"run": {
"description": "Inicia um alvo de CLI compatível pelo OmniRoute"
},
"setupClaude": {
"description": "Gera profiles do Claude Code em ~/.claude/profiles a partir do catálogo de modelos do OmniRoute"
},
@@ -1297,30 +1294,12 @@
"description": "Gerencia tokens de acesso CLI com escopo (modo remoto)"
},
"configure": {
"description": "Escolhe um provedor+modelo do servidor ativo e configura uma CLI local compatível"
"description": "Escolhe um provedor+modelo do servidor ativo e grava uma configuração de CLI local"
},
"launchCodex": {
"description": "Inicia o Codex CLI apontando para o OmniRoute (local ou VPS remoto)"
},
"setupCodex": {
"description": "Gera os arquivos de perfil ~/.codex a partir do catálogo de modelos ao vivo do OmniRoute"
},
"packs": {
"description": "Gerencia packs opcionais de runtime (ML / automação de navegador)",
"listDescription": "Lista os packs opcionais e seu estado de instalação",
"installDescription": "Instala um pack opcional no DATA_DIR",
"verifyDescription": "Verifica os packs instalados contra o índice de checksums embarcado",
"removeDescription": "Remove um pack opcional instalado",
"sourceOpt": "Diretório com os payloads dos packs e o índice de packs",
"warnNoIndex": "optional-packs.index.json não encontrado — install/verify indisponíveis neste checkout (instaladores desktop o embarcam)",
"errUnknown": "pack desconhecido: {name}",
"errNoIndex": "índice de packs não encontrado; passe --source <dir> com o payload do pack (instaladores desktop o embarcam ao lado do app)",
"installed": "pack \"{name}\" instalado e verificado em {dir}",
"restartHint": "reinicie o servidor OmniRoute (ou o app desktop) para o runtime reconhecer o pack",
"removed": "pack \"{name}\" removido",
"notInstalled": "o pack \"{name}\" não estava instalado",
"verifyOk": "todos os packs instalados verificados",
"verifyFailed": "{count} pack(s) falharam na verificação",
"noneInstalled": "nenhum pack opcional instalado"
}
}

View File

@@ -1,109 +0,0 @@
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

@@ -114,30 +114,24 @@ export function isBetterSqliteBinaryValid() {
export function npmInstallRuntime(pkgs, opts = {}) {
const cwd = ensureRuntimeDir();
// Persist to the runtime package.json (exact version) instead of --no-save so a later
// install of a sibling runtime dep (e.g. systray2 from trayRuntime.ts, which writes to the
// same runtime dir) does not prune this package as "extraneous" — that pruning otherwise
// reproduces "No SQLite driver available" after a tray install removes better-sqlite3.
const npmArgs = [
"install",
...pkgs,
"--no-audit",
"--no-fund",
"--prefer-online",
"--save-exact",
];
// On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly
// so we never set shell:true (which would propagate env and enable injection).
const isWin = platform() === "win32";
const isBun = Boolean(process.versions.bun);
let exe, args, displayCmd;
if (isBun) {
const bunArgs = ["add", ...pkgs, "--trust"];
[exe, args] = isWin ? ["cmd.exe", ["/c", "bun", ...bunArgs]] : ["bun", bunArgs];
displayCmd = `bun ${bunArgs.join(" ")}`;
} else {
const npmArgs = [
"install",
...pkgs,
"--no-audit",
"--no-fund",
"--prefer-online",
"--save-exact",
...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
];
[exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
displayCmd = `npm ${npmArgs.join(" ")}`;
}
const [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
if (!opts.silent) {
process.stdout.write(`[omniroute][runtime] ${displayCmd}\n`);
process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`);
}
const res = spawnSync(exe, args, {
cwd,

View File

@@ -5,14 +5,10 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./
async function loadSqlite() {
if (process.versions.bun) {
try {
return { Database: (await import("bun:sqlite")).Database, driver: "bun:sqlite" };
} catch (bunError) {
// fall through to better-sqlite3 if bun:sqlite fails
}
return { Database: (await import("bun:sqlite")).Database };
}
try {
return { Database: (await import("better-sqlite3")).default, driver: "better-sqlite3" };
return { Database: (await import("better-sqlite3")).default };
} catch (error) {
return { error };
}
@@ -90,14 +86,12 @@ export function normalizeBunSqliteParams(params) {
export function createSqliteNativeError(error) {
const message = error instanceof Error ? error.message : String(error);
const isBun = Boolean(process.versions.bun);
const rebuildCmd = isBun ? "bun add better-sqlite3 --trust" : "npm rebuild better-sqlite3";
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
return new Error(
`better-sqlite3 native binding is incompatible with this runtime. ` +
`Run \`${rebuildCmd}\` in the OmniRoute project and try again. ` +
`Or run: omniroute runtime repair ` +
`(rebuilds into a user-writable runtime; works without a C++ toolchain).`
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again. " +
"Or run: omniroute runtime repair " +
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
);
}
if (
@@ -106,9 +100,10 @@ export function createSqliteNativeError(error) {
message.includes("Cannot find module 'better-sqlite3'")
) {
return new Error(
`better-sqlite3 native binding could not be found (no prebuilt addon for this platform). ` +
`Run: omniroute runtime repair ` +
`(rebuilds into a user-writable runtime; works without a C++ toolchain).`
"better-sqlite3 native binding could not be found (no prebuilt addon for this platform). " +
"This is common under `npx`, which runs a fresh, ephemeral install that never built the addon. " +
"Run: omniroute runtime repair " +
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
);
}
return error;
@@ -116,7 +111,7 @@ export function createSqliteNativeError(error) {
async function openSqliteDatabase(dbPath, options = {}) {
const loaded = await loadSqlite();
if (loaded.driver === "bun:sqlite" || (process.versions.bun && !loaded.Database)) {
if (process.versions.bun) {
if (options.fileMustExist && !fs.existsSync(dbPath)) {
throw new Error(`SQLite file does not exist: ${dbPath}`);
}

View File

@@ -121,16 +121,7 @@ function writeLinuxSystemdUnit(cliPath) {
"Wants=network-online.target",
"",
"[Service]",
// Type=notify + WatchdogSec: the server sends READY=1 once listening and
// WATCHDOG=1 every 60s; if its event loop ever blocks (frozen process),
// the pings stop and systemd kills+restarts the service. NotifyAccess=all
// because the pings come from the server child, not the serve supervisor.
// Foreground serve only: `--daemon` escapes the cgroup and would break
// the notify handshake.
"Type=notify",
"NotifyAccess=all",
"WatchdogSec=180",
"TimeoutStartSec=300",
"Type=simple",
`ExecStart=${buildServeExecLine(cliPath, { tray: false })}`,
"Restart=on-failure",
"RestartSec=5",

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from "react";
import { render, Box, Text, useInput } from "ink";
import Spinner from "ink-spinner";
import { apiFetch } from "../api.mjs";
import { DataTable } from "../tui-components/DataTable.jsx";
import { ProgressBar } from "../tui-components/ProgressBar.jsx";
@@ -32,20 +31,22 @@ const TABLE_SCHEMA = [
{ key: "error", header: "Error", width: 28, formatter: (v) => (v ? v.slice(0, 26) : "") },
];
async function testOne(connectionId, model, baseUrl, apiKey) {
async function testOne(provider, model, baseUrl, apiKey) {
const headers = {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
};
const start = Date.now();
try {
const res = await apiFetch(`/api/providers/${encodeURIComponent(connectionId)}/test`, {
const res = await fetch(`${baseUrl}/api/v1/providers/test`, {
method: "POST",
body: model ? { validationModelId: model } : {},
baseUrl,
token: apiKey,
timeout: 30000,
acceptNotOk: true,
headers,
body: JSON.stringify({ provider, model }),
signal: AbortSignal.timeout(30000),
});
const latencyMs = Date.now() - start;
const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` };
return { status: data.valid ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error };
const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` };
return { status: data.success ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
@@ -62,7 +63,6 @@ function ProvidersTestAllApp({ providers, baseUrl, apiKey, concurrency = 4, onEx
const [rows, setRows] = useState(() =>
providers.map((p, i) => ({
id: i,
connectionId: p.connectionId ?? p.id,
provider: p.provider ?? p.id ?? String(p),
model: p.model ?? p.defaultModel ?? "",
status: STATUS.PENDING,
@@ -91,7 +91,7 @@ function ProvidersTestAllApp({ providers, baseUrl, apiKey, concurrency = 4, onEx
const row = queue[cursor++];
running++;
update(row.id, { status: STATUS.RUNNING });
testOne(row.connectionId, row.model, resolved, apiKey).then((result) => {
testOne(row.provider, row.model, resolved, apiKey).then((result) => {
update(row.id, result);
running--;
nextSlot();

View File

@@ -1,53 +1,22 @@
import crypto from "node:crypto";
const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1";
const 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 function deriveCliToken(machineIdModule, salt) {
try {
// node-machine-id is CommonJS: under `await import()` its exports land on
// `.default`, so destructuring `machineIdSync` off the namespace yields
// undefined and calling it throws — which the catch below turned into an
// empty token, silently disabling CLI auth for every management request.
// Same resolution order as src/lib/machineToken.ts.
const machineIdSync =
machineIdModule?.machineIdSync || machineIdModule?.default?.machineIdSync;
if (typeof machineIdSync !== "function") return "";
// machineIdSync(true) returns the original unhashed hardware ID — mirrors
// getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening).
const rawId = machineIdSync(true);
if (!rawId) return "";
return crypto.createHmac("sha256", rawId).update(salt).digest("hex");
} catch {
return "";
}
}
export async function getCliToken() {
const salt = getActiveSalt();
if (_cached !== null && _cachedSalt === salt) return _cached;
if (_cached !== null) return _cached;
try {
const imported = await import("node-machine-id");
const token = deriveCliToken(imported, salt);
if (!token) {
// Swallowing here changes control flow (every management call goes out
// unauthenticated and 401s), so leave a breadcrumb rather than failing mute.
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled");
}
_cached = token;
} catch (e) {
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled:", e);
const { machineIdSync } = await import("node-machine-id");
const mid = machineIdSync();
_cached = crypto
.createHash("sha256")
.update(mid + SALT)
.digest("hex")
.substring(0, 32);
} catch {
_cached = "";
}
_cachedSalt = salt;
return _cached;
}

View File

@@ -1,122 +0,0 @@
import { printError, printInfo } from "../io.mjs";
/**
* Container guard for CLI-tool config writes.
*
* `omniroute setup-*` writes to `~/.codex`, `~/.claude`, ... — paths that only
* mean something on the operator's host. Run the same command inside the
* OmniRoute container and the write "succeeds" into an ephemeral layer that no
* host CLI ever reads and that disappears with the container. This guard turns
* that silent no-op into an actionable refusal.
*
* Bind-mounted targets (the compose `host` profile) are allowed through: the
* mount is the operator's explicit statement that the path reaches the host.
*/
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
/** Exit code for a refused write — matches the CLI's usage-error convention. */
export const CONTAINER_WRITE_EXIT_CODE = 2;
function envAllowsContainerWrite(env = process.env) {
return TRUE_VALUES.has(
String(env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE ?? "")
.trim()
.toLowerCase()
);
}
/**
* Classify a pending config write.
*
* @param {string} targetPath Absolute path the command is about to write.
* @param {{
* toolLabel?: string,
* hostCommand?: string,
* allowContainerWrite?: boolean,
* dryRun?: boolean,
* env?: NodeJS.ProcessEnv,
* deps?: object,
* }} options
* @returns {Promise<{ok: boolean, message?: string, warning?: string}>}
*/
export async function assertHostConfigTarget(targetPath, options = {}) {
const {
toolLabel,
hostCommand,
allowContainerWrite = false,
dryRun = false,
env = process.env,
deps,
} = options;
let describeContainerTarget;
let buildContainerWriteRefusal;
let CLI_OVERRIDE_HINT;
try {
// `.ts` extension is required so the published package (which ships only TS
// source, resolved through tsx) can load these. See #2509.
({ describeContainerTarget } = await import("../../../src/shared/utils/containerEnv.ts"));
({ buildContainerWriteRefusal, CLI_OVERRIDE_HINT } =
await import("../../../src/shared/utils/containerConfigGuard.ts"));
} catch {
// Fail open: a guard that cannot load must not block a legitimate host run.
return { ok: true };
}
const info = describeContainerTarget(targetPath, deps);
if (!info.ephemeral) return { ok: true };
if (dryRun) {
return {
ok: true,
warning:
`[dry-run] ${targetPath} is inside the container and is not mounted from the host — ` +
`a real run would be refused. See --allow-container-write.`,
};
}
if (allowContainerWrite || envAllowsContainerWrite(env)) {
return {
ok: true,
warning:
`Writing to ${targetPath} inside the container as requested — this file is lost when ` +
`the container is recreated and host CLIs will not see it.`,
};
}
return {
ok: false,
message: buildContainerWriteRefusal(targetPath, {
toolLabel,
hostCommand,
overrideHint: CLI_OVERRIDE_HINT,
}),
};
}
/**
* Container check for commands that write nothing but still print host-oriented
* instructions (setup-cursor). Fails closed to `false` so a broken import never
* turns into a spurious warning.
*/
export async function isContainerRuntime(deps) {
try {
const { isRunningInContainer } = await import("../../../src/shared/utils/containerEnv.ts");
return isRunningInContainer(deps);
} catch {
return false;
}
}
/**
* Guard + report. Returns 0 to continue, or CONTAINER_WRITE_EXIT_CODE when the
* caller should abort and return that code.
*/
export async function guardHostConfigTarget(targetPath, options = {}) {
const result = await assertHostConfigTarget(targetPath, options);
if (result.warning) printInfo(result.warning);
if (result.ok) return 0;
printError(result.message);
return CONTAINER_WRITE_EXIT_CODE;
}

View File

@@ -94,15 +94,10 @@ export function ensureAndroidCacheDir(options = {}) {
*/
export function isFatalInstrumentationHookFailure(text) {
if (!text) return false;
// Next.js wraps ANY throw inside instrumentation.register() with the generic
// "An error occurred while loading instrumentation hook:" prefix, on every
// platform (node_modules/next/dist/server/web/globals.js). That prefix alone
// therefore cannot identify the Android/Termux cache-probe failure — a bare
// generic instrumentation error on win32/desktop would be misreported as the
// Android bug and hide the real cause. Only match when the text actually
// carries the Android platform marker that Next's getCacheDirectory() emits.
// #10028
return /Unsupported platform:\s*android/i.test(text);
return (
/Unsupported platform:\s*android/i.test(text) ||
/error occurred while loading instrumentation hook/i.test(text)
);
}
/**

View File

@@ -102,7 +102,7 @@ export async function waitForServer(port, timeout = 60000) {
// - "not-listening": nothing is accepting connections on the port at all.
async function pollHealthOnce(port) {
try {
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`, {
const res = await fetch(`http://localhost:${port}/api/monitoring/health`, {
signal: AbortSignal.timeout(2000),
});
return res.ok ? "ready" : "fast-reject";

View File

@@ -1,57 +0,0 @@
import { hostname, platform } from "node:os";
/**
* Resolve the bind host passed to the standalone Next.js server.
*
* HOSTNAME is a standard shell variable on Unix-like systems, so only the
* dedicated OmniRoute variable is treated as configuration there. Windows
* keeps the legacy HOSTNAME fallback for compatibility with existing .env
* files, while still ignoring the OS-reported machine name.
*
* @param {NodeJS.ProcessEnv} [env]
* @param {NodeJS.Platform} [runtimePlatform]
* @param {string} [machineHostname]
* @returns {string}
*/
export function resolveServerHost(
env = process.env,
runtimePlatform = platform(),
machineHostname = hostname()
) {
if (env.OMNIROUTE_SERVER_HOST) return env.OMNIROUTE_SERVER_HOST;
if (runtimePlatform === "win32" && env.HOSTNAME && env.HOSTNAME !== machineHostname) {
return env.HOSTNAME;
}
return "0.0.0.0";
}
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
/**
* Boot-time exposure warning (GHSA-wmgv-ph3p-rv57): the shipped default binds
* all interfaces while the inference plane requires no credentials, so any
* LAN peer can spend the operator's quota. That local-first posture is a
* deliberate, documented default — but it must be LOUD at startup so an
* operator who never read the docs still learns the two escape hatches.
*
* Returns the warning text when the server will listen on a non-loopback
* interface with no API-key requirement, or null when the exposure is closed.
*
* @param {NodeJS.ProcessEnv} [env]
* @param {string} [host]
* @returns {string | null}
*/
export function resolveExposureWarning(env = process.env, host = resolveServerHost(env)) {
if (LOOPBACK_HOSTS.has(host)) return null;
const requireKey = String(env.REQUIRE_API_KEY || "")
.trim()
.toLowerCase();
if (requireKey === "true" || requireKey === "1" || requireKey === "yes") return null;
return (
`SECURITY: listening on ${host} with NO API-key requirement — the inference ` +
`plane (/v1/*) is reachable by ANY device that can route to this host, and ` +
`requests are billed to your configured providers. This local-first default ` +
`is intentional, but on an untrusted network either set REQUIRE_API_KEY=true ` +
`or bind loopback with OMNIROUTE_SERVER_HOST=127.0.0.1.`
);
}

View File

@@ -44,18 +44,6 @@ export function getSecureFloorForMajor(major) {
}
export function getNodeRuntimeSupport(version = process.versions.node) {
if (process.versions.bun) {
return {
nodeVersion: `bun-${process.versions.bun} (Node.js API ${version})`,
nodeCompatible: true,
reason: "supported-bun",
supportedRange: SUPPORTED_NODE_RANGE + " || Bun >=1.1.0",
supportedDisplay: SUPPORTED_NODE_DISPLAY + ", or Bun 1.1+",
recommendedVersion: `v${RECOMMENDED_NODE_VERSION}`,
minimumSecureVersion: null,
};
}
const parsed = parseNodeVersion(version);
const secureFloor = getSecureFloorForMajor(parsed.major);
const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false;

View File

@@ -17,12 +17,7 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
let updateNotifier = null;
try {
updateNotifier = (await import("update-notifier")).default;
} catch {
// update-notifier is optional in pruned standalone environments
}
import updateNotifier from "update-notifier";
import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs";
import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs";
import { getDefaultDataDir } from "./cli/data-dir.mjs";
@@ -124,9 +119,6 @@ function loadEnvFile() {
addEnvPath(join(ROOT, ".env"));
}
const keyOrigin = new Map();
const shadowed = new Map();
for (const envPath of envPaths) {
try {
if (existsSync(envPath)) {
@@ -139,31 +131,19 @@ function loadEnvFile() {
const key = trimmed.slice(0, eqIdx).trim();
if (process.env[key] === undefined) {
process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1));
keyOrigin.set(key, envPath);
} else if (!shadowed.has(key)) {
// The line is inert: something set this key first. Report it once
// per key, whether the winner was an earlier file or the process
// environment (#6194: a shell's own HOSTNAME beat the .env and the
// server bound to the wrong address in silence).
shadowed.set(key, { winner: keyOrigin.get(key) ?? null, loser: envPath });
}
}
}
loadedEnvPaths.push(envPath);
}
} catch (err) {
console.warn(` \x1b[33m⚠ Could not read ${envPath}: ${err?.message ?? err}\x1b[0m`);
} catch {
// Ignore errors reading env files.
}
}
for (const envPath of loadedEnvPaths) {
console.log(` \x1b[2m📋 Loaded env from ${envPath}\x1b[0m`);
}
for (const [key, { winner, loser }] of shadowed) {
const setter = winner ? winner : "the environment";
console.warn(` \x1b[33m⚠ ${key} in ${loser} is ignored, ${setter} set it first\x1b[0m`);
}
}
loadEnvFile();
@@ -256,9 +236,8 @@ if (shouldProvisionStorageKey(process.argv)) {
// Register update notifier — checks npm once per 24h, notifies on exit via stderr.
const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
const _notifier = updateNotifier ? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }) : null;
const _notifier = updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 });
process.on("exit", () => {
if (!_notifier || !_notifier.update) return;
if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return;
if (process.env.CI) return;
if (process.argv.includes("--quiet") || process.argv.includes("-q")) return;

View File

@@ -1,2 +0,0 @@
- **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

@@ -1 +0,0 @@
- **feat(cli):** container-aware auto-config — `setup-*`, `omniroute configure`, `omniroute config set` and the CLI-tool config APIs now refuse to write into a containerised OmniRoute's ephemeral home (CLI exits `2`, API returns `422` with `containerEphemeralTarget`) and point at the host-CLI or bind-mount setup instead; `--allow-container-write` / `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` opt back in. Also fixes `CLI_CONFIG_HOME` so the Compose `host` profile's `/host-home` bind mounts are honoured instead of silently falling back to the container home. (#10057)

View File

@@ -1 +0,0 @@
- **feat(resilience):** warn when `/healthz` is served under event-loop lag ≥200ms so a slow 200 is visible as sick, not healthy ([#10303](https://github.com/diegosouzapw/OmniRoute/issues/10303))

View File

@@ -1 +0,0 @@
- **feat(docker):** add `GET`/`HEAD` `/livez` as a process-alive probe, distinct from `/healthz` readiness ([#10316](https://github.com/diegosouzapw/OmniRoute/issues/10316))

View File

@@ -1 +0,0 @@
- 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

@@ -1,2 +0,0 @@
- **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

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- **feat(providers):** accept `response_format=ogg` on `/v1/audio/speech` as an alias for the existing Opus/Ogg encoder ([#10587](https://github.com/diegosouzapw/OmniRoute/issues/10587))

View File

@@ -1 +0,0 @@
- **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

@@ -1 +0,0 @@
- feat(server): emit systemd sd_notify READY/WATCHDOG/STOPPING (generated unit becomes Type=notify with WatchdogSec=180) so a frozen server process is killed and restarted by systemd instead of lingering undetected

View File

@@ -1,2 +0,0 @@
- **feat(providers):** add the TabiToken NewAPI gateway (`tabitoken`) and teach the existing HCNSec entry (`hcnsec`) the three further protocols it actually serves. TabiToken leaves the NewAPI pricing endpoint public, so its catalog is read from the host rather than guessed: four Claude models, each reporting the Anthropic and OpenAI protocols. HCNSec shipped OpenAI-only; probing the host showed `/v1/messages`, `/v1/responses` and the Gemini `/v1beta` path all reach its token layer, so each is now declared as an alternate format — with its default format, base URL, auth scheme and regional catalog classification untouched. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil
- **feat(sse):** allow an alternate protocol to build its own upstream URL. `AlternateFormat` gained an optional `urlBuilder`, because the Gemini protocol carries the model inside the path (`{base}/{model}:generateContent`) and the existing `chatPath`/`urlSuffix` fields are constants that cannot express it. The route builder is extracted as `buildGeminiGenerateContentUrl` and shared with the native `gemini` provider so the two consumers cannot drift on the `?alt=sse` streaming suffix. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil

View File

@@ -1 +0,0 @@
- **feat(call_logs):** persist the per-call error family in `call_logs.error_type` and expose a failure breakdown (`errorBreakdown`) in the usage analytics endpoint, reusing the existing production classifier ([#10670](https://github.com/diegosouzapw/OmniRoute/issues/10670))

View File

@@ -1 +0,0 @@
- **feat(proxy):** the proxy-health sweep and `GET /api/settings/proxies/egress` now report an anonymous summary of egress-IP sharing — how many rotation groups share an egress IP and the largest number of accounts behind one IP — computed from persisted `proxy_logs` over a 24h window. No IPs and no account identities by default; `PROXY_LOG_INCLUDE_IPS=true` restores raw details. ([#10677](https://github.com/diegosouzapw/OmniRoute/issues/10677))

View File

@@ -1 +0,0 @@
- **docs(guides):** OmniRoute now serves VS Code's **native Copilot Chat model picker** through the [OmniCopilot](https://github.com/diegosouzapw/OmniCopilot) extension ([Marketplace](https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot) · [Open VSX](https://open-vsx.org/extension/diegosouzapw/omnicopilot) — Cursor, Windsurf, VSCodium, Theia…) — no Copilot subscription needed since VS Code 1.122. New [`docs/guides/VSCODE-COPILOT.md`](docs/guides/VSCODE-COPILOT.md) covers setup, how the picker collapses the `dual`-prefix catalog via `GET /v1/models?prefix=alias`, and the **build-time** `DASHBOARD_ALLOW_EMBED=vscode` flag that renders the dashboard in an editor tab ([#10697](https://github.com/diegosouzapw/OmniRoute/pull/10697))

View File

@@ -1 +0,0 @@
- **feat(docker):** `DASHBOARD_ALLOW_EMBED` is now a Docker build argument — `docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode` produces an image whose dashboard renders inside the VS Code Simple Browser (OmniCopilot's `dashboardOpen: "editor"`). Previously the flag was only reachable from a source build: Docker silently drops a `--build-arg` with no matching `ARG`, so the operator got the default image and no error. Builder-stage only and empty by default — the runtime stages deliberately do not carry it, and the unframable default posture is unchanged ([#10701](https://github.com/diegosouzapw/OmniRoute/pull/10701))

View File

@@ -1 +0,0 @@
- **feat(providers):** new `cursor-api` provider (card "Cursor API", alias `cua`): connect a Cursor user API key (`crsr_…`) and route `cursor-api/<model>` through the existing Cursor agent executor (the key is exchanged for a 1h session token and cached), plus a `/api/cursor-cli/*` passthrough so the Cursor CLI itself runs through OmniRoute (`CURSOR_API_ENDPOINT=http://<omniroute>/api/cursor-cli`, `CURSOR_API_KEY=<OmniRoute key>`) with every RPC attributed and logged. The IDE `cursor` provider is unchanged. (#10729)

View File

@@ -1 +0,0 @@
- **feat(api):** `GET /api/health` now answers `{ status, timestamp }` without a key. Until now the path had no route, so the management-auth boundary answered first with a 401 — indistinguishable from a wrong key or an unknown route, which left Docker HEALTHCHECKs and Kubernetes probes unable to tell "down" from "misconfigured". Kept deliberately minimal: version, uptime and memory stay behind the authenticated `/api/monitoring/health` ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10771)).

View File

@@ -1 +0,0 @@
- feat(routing): make Task-Aware Smart Routing's detection patterns operator-configurable via `settings.taskRouting.patternOverrides` (`PUT /api/settings/task-routing`) — the built-in patterns are English-only, so a non-English dashboard had no recourse short of turning detection off entirely; an override now replaces the pattern list for one task type without touching the rest (#10783)

View File

@@ -1 +0,0 @@
- feat(api): accept PATCH on /api/combos/[id], the verb the OpenAPI spec already documents (#10869)

View File

@@ -1 +0,0 @@
- **feat(sse):** add GLM-5.3 support (`glm-5.3`, `glm-5.3-high`, `glm-5.3-low`) across the z.ai first-party providers, mapping the upstream `reasoning_effort` request parameter to the existing 5.2 tier UX ([#10896](https://github.com/diegosouzapw/OmniRoute/pull/10896)) — thanks @phuongddx

View File

@@ -1 +0,0 @@
- **feat(home):** add a live **Recent Requests** panel beside the home Provider Topology (polls `GET /api/usage/call-logs?excludeTests=1` every ~3s, gated by the topology appearance toggle + page visibility). `excludeTests` is now an allowlist of real provider inference (`/v1/%` or `/api/v1/%`), applied before `LIMIT`, so connection-test/model-sync/management rows can never leak into the feed ([#10897](https://github.com/diegosouzapw/OmniRoute/pull/10897), extracted from [#8450](https://github.com/diegosouzapw/OmniRoute/pull/8450)) — thanks @nguyenha935

View File

@@ -1 +0,0 @@
- **feat(rankings):** free provider rankings now expose a `reliability` field (raw `testStatus`/`rateLimitedUntil` per connection plus a `healthy`/`degraded`/`down` state, reusing the `ProviderHealthState` vocabulary of the provider health matrix) when the configured/available filters are active — derived from already-loaded data, without touching the ranking order ([#10909](https://github.com/diegosouzapw/OmniRoute/pull/10909))

View File

@@ -1,8 +0,0 @@
- `feat(resilience)`: when an allowlisted provider (opencode family) answers
429 classified `quota_exhausted` or `rate_limit_exceeded` and its free-tier
quota is bucketed by egress IP (#9611), every connection of that family
sharing the IP is cooled down together before the rotation tries them — one
guaranteed-failed upstream call per episode instead of N, on the combo path
as well. For the allowlisted family a 429 now cools the connection instead
of locking a single model. Exclusive allowlist, never terminal, best-effort
when the egress IP is unknown (#10920).

View File

@@ -1 +0,0 @@
- **feat(rankings):** free provider rankings can now report what each provider actually served — `reliability.usage` (requests, successes, success rate over a window) behind the opt-in `withUsage`/`usageRange` query parameters, so a provider that answers every call with an error is no longer described as healthy ([#10926](https://github.com/diegosouzapw/OmniRoute/pull/10926))

View File

@@ -1 +0,0 @@
- **feat(providers):** add Logfare as a free OpenAI-compatible provider — dashboard card with a Free badge and request-logging disclosure (every prompt/completion is logged for research; opt out at logfare.ai/consent), live model discovery from `https://logfare.ai/v1/models` (20 models, 11 chat-capable: kimi-k3, deepseek-v4-pro, glm-5.2, gpt-5.6-luna, minimax-m3…), full chat/streaming through the existing OpenAI-compatible path, the real Logfare logo on the card, and a listing in the free-tiers guide. ([#10987](https://github.com/diegosouzapw/OmniRoute/pull/10987))

View File

@@ -1 +0,0 @@
- **feat(providers):** let operators declare per-provider error rules through `settings.providerErrorRules` instead of patching the catalog — an operator-supplied rule for a provider is consulted before the built-in `providerRuleRegistry`, receives the raw error text, and has its declared scope/cooldown/reason actually honored end to end, for any provider (declaring the rule is the opt-in — no extra allowlist entry needed). Matches are plain case-insensitive substrings (never RegExp) and bounded to 50 rules to keep the hot path safe ([#11104](https://github.com/diegosouzapw/OmniRoute/pull/11104))

View File

@@ -1 +0,0 @@
- **feat(api):** `/api/usage/om-usage` gains a structured form — `?format=json` returns the key's own usage as `ApiKeyUsageLimitStatus` + `UsageSnapshot` instead of `text/plain`. This is the surface a UI (the OmniCopilot panel) consumes to show a key holder their daily/weekly spend and quota reset. The route is self-service (the caller's own key, gated by `allowUsageCommand`), not the management surface; refusals come back as a discriminated `{ "allowed": false, "error": … }` so a UI can tell "not allowed" apart from "allowed but nothing cached yet". The endpoint was previously undocumented in `API_REFERENCE.md`; it now has a section ([#11190](https://github.com/diegosouzapw/OmniRoute/pull/11190))

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