mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-27 10:26:03 +03:00
Compare commits
58 Commits
main
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c1f44b265 | ||
|
|
ea9844ce04 | ||
|
|
70aef29526 | ||
|
|
0cd1e5902f | ||
|
|
d6ea20bfda | ||
|
|
996ac484f6 | ||
|
|
8330111c93 | ||
|
|
64be300566 | ||
|
|
1419088022 | ||
|
|
a6731dc500 | ||
|
|
0558f879c6 | ||
|
|
b64358b913 | ||
|
|
26b6653656 | ||
|
|
274f6b3a30 | ||
|
|
0874adacc3 | ||
|
|
635350ebba | ||
|
|
512464c829 | ||
|
|
8660319159 | ||
|
|
8e5e25099e | ||
|
|
027af5a92f | ||
|
|
7f53f6bb32 | ||
|
|
42cf06e4af | ||
|
|
4941a0d462 | ||
|
|
c89adad0b4 | ||
|
|
b39a9db4fc | ||
|
|
ae0df1150d | ||
|
|
792a131f17 | ||
|
|
69e1511710 | ||
|
|
1387b48db6 | ||
|
|
b1607c860d | ||
|
|
bc1ef8dd96 | ||
|
|
fe7576cee5 | ||
|
|
12664b1df1 | ||
|
|
b2e54618f7 | ||
|
|
5ea160140f | ||
|
|
4ba27c17da | ||
|
|
a7ff81bb95 | ||
|
|
3c613d16c6 | ||
|
|
5af49039da | ||
|
|
ecf0089ee9 | ||
|
|
40f811eb7b | ||
|
|
df2e6ff410 | ||
|
|
c5002fc426 | ||
|
|
240f466f54 | ||
|
|
081bf30bbd | ||
|
|
7e88b311c8 | ||
|
|
de62197a7c | ||
|
|
911567758d | ||
|
|
9a77456890 | ||
|
|
a17ccaf86c | ||
|
|
52001d2ea5 | ||
|
|
a509e7b479 | ||
|
|
11f0199ba1 | ||
|
|
3d3ad84d4f | ||
|
|
0bf2fcbbf4 | ||
|
|
3cb8fe8579 | ||
|
|
13c4316c16 | ||
|
|
e6675cccaf |
@@ -77,8 +77,6 @@ bun.lock
|
||||
# Agent config
|
||||
.agents
|
||||
.gemini
|
||||
.claude
|
||||
.source
|
||||
|
||||
# Misc
|
||||
llm.txt
|
||||
@@ -125,4 +123,3 @@ app.__qa_backup/
|
||||
.worktrees
|
||||
.next-playwright/
|
||||
cloud/
|
||||
electron/dist-electron
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.sh]
|
||||
indent_size = 4
|
||||
637
.env.example
637
.env.example
@@ -56,12 +56,10 @@ STORAGE_ENCRYPTION_KEY_VERSION=v1
|
||||
DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
|
||||
# ── Redis (Rate Limiting) ──
|
||||
# Redis connection URL for the rate limiter backend. OPT-IN: leave this
|
||||
# commented out to use the built-in in-memory rate limiter. Setting it to a
|
||||
# non-running localhost (#4878) makes ioredis flood "[REDIS] Error:" logs.
|
||||
# Redis connection URL for the rate limiter backend.
|
||||
# Used by: src/shared/utils/rateLimiter.ts
|
||||
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
# Default: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 3. NETWORK & PORTS
|
||||
@@ -73,25 +71,16 @@ DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
# Default: 20128
|
||||
PORT=20128
|
||||
|
||||
# Base path (URL subpath) when serving OmniRoute behind a reverse proxy under a subpath.
|
||||
# Used by: next.config.mjs — sets Next.js `basePath`; auth redirects are basePath-aware.
|
||||
# Default: "" (served at the domain root). Example: /omniroute to serve under https://host/omniroute
|
||||
# OMNIROUTE_BASE_PATH=
|
||||
|
||||
# Split-port mode: serve Dashboard and API on separate ports for network isolation.
|
||||
# Used by: src/lib/runtime/ports.ts — overrides PORT for each service.
|
||||
# API_PORT=20129
|
||||
# API_HOST=0.0.0.0
|
||||
# DASHBOARD_PORT=20128
|
||||
|
||||
# Connection backpressure: cap concurrent in-flight chat connections (503 + Retry-After when full).
|
||||
# Used by: src/sse/utils/backpressure.ts — disabled when unset/0.
|
||||
# OMNI_MAX_CONCURRENT_CONNECTIONS=0
|
||||
|
||||
# Port for the real-time WebSocket live monitoring server.
|
||||
# Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts
|
||||
# Default: 20132
|
||||
# LIVE_WS_PORT=20132
|
||||
# Default: 20129
|
||||
# LIVE_WS_PORT=20129
|
||||
|
||||
# Bind address for the live WebSocket server.
|
||||
# Default: 127.0.0.1 (loopback only). Set to 0.0.0.0 to expose on LAN —
|
||||
@@ -101,29 +90,11 @@ PORT=20128
|
||||
# Comma-separated extra origins allowed to open a live WebSocket. The
|
||||
# loopback dashboard origins are already permitted by default; use this
|
||||
# var when fronting the server with a domain (e.g. https://omni.local).
|
||||
# ⚠️ When using NEXT_PUBLIC_LIVE_WS_PUBLIC_URL or exposing the WS server
|
||||
# beyond loopback, this MUST include the public origin(s) — otherwise
|
||||
# the Origin allow-list check will reject all browser connections.
|
||||
# Example: LIVE_WS_ALLOWED_ORIGINS=https://omni.local,https://dashboard.example.com,https://ws.my-ai.com
|
||||
# LIVE_WS_ALLOWED_ORIGINS=https://omni.local,https://dashboard.example.com
|
||||
|
||||
# Comma-separated extra hostnames allowed to open a live WebSocket (LAN/Tailscale).
|
||||
# Unlike LIVE_WS_ALLOWED_ORIGINS (which matches full origin URLs), this matches
|
||||
# only the host portion — useful for wildcard-ish LAN/Tailscale setups.
|
||||
# Used by: src/server/ws/liveServerAllowList.ts
|
||||
# Example: LIVE_WS_ALLOWED_HOSTS=omni.local,tailscale-host,192.168.1.50
|
||||
# LIVE_WS_ALLOWED_HOSTS=omni.local,tailscale-host,192.168.1.50
|
||||
|
||||
# Public URL for the live dashboard WebSocket (client-side, browser only).
|
||||
# Set this when fronting the WS server with a reverse proxy or Cloudflare Tunnel.
|
||||
# The browser will connect to this URL instead of ws://hostname:20132.
|
||||
# The path portion of this URL (e.g. ws://localhost:20132/live-ws -> /live-ws) is also used by the dev proxy
|
||||
# (scripts/dev/standalone-server-ws.mjs) and the handshake response to route
|
||||
# WebSocket upgrades. Default path: /live-ws.
|
||||
# Used by: src/hooks/useLiveDashboard.ts, src/app/api/v1/ws/route.ts,
|
||||
# scripts/dev/standalone-server-ws.mjs, and scripts/start-ws-server.mjs.
|
||||
# Example: NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=wss://ws.my-ai.com/live-ws
|
||||
# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=ws://localhost:20132/live-ws
|
||||
# Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs.
|
||||
# Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle).
|
||||
# OMNIROUTE_DISABLE_LIVE_WS=0
|
||||
|
||||
# Enable the real-time dashboard WebSocket server.
|
||||
# Used by: src/server/ws/liveServer.ts, scripts/start-ws-server.mjs
|
||||
@@ -138,19 +109,13 @@ PORT=20128
|
||||
# Used by: src/app/api/v1/relay/chat/completions/route.ts
|
||||
# RELAY_IP_PER_MINUTE=30
|
||||
|
||||
# Bundler selection for `npm run dev`. Set to 0 to fall back to webpack.
|
||||
# Default is 1 (Turbopack). PR #4092 had forced webpack because earlier
|
||||
# Turbopack 16.2.x panicked on the OmniRoute module graph with "internal error:
|
||||
# entered unreachable code: there must be a path to a root"
|
||||
# (turbopack-core/module_graph/mod.rs:662). That panic no longer reproduces on
|
||||
# the pinned Next 16.2.9 — verified across a broad cold-compile sweep (36
|
||||
# dashboard routes + open-sse-heavy API routes incl. /api/v1/chat/completions,
|
||||
# /api/v1/models, /api/mcp) and repeated HMR rebuilds: zero panics. Turbopack
|
||||
# also keeps dev memory far lower on the edit→rebuild loop (HMR rebuild RSS stays
|
||||
# ~flat vs webpack's monotonic growth), which mitigates the dev-server OOM on
|
||||
# this 60+ route app. The production build still uses webpack (build pipeline is
|
||||
# unaffected by this dev-only flag).
|
||||
OMNIROUTE_USE_TURBOPACK=1
|
||||
# Bundler selection for `npm run dev`. Set to 1 to opt into Turbopack.
|
||||
# Default is 0 (webpack) because Turbopack 16.2.x panics on the OmniRoute
|
||||
# module graph with "internal error: entered unreachable code: there must be
|
||||
# a path to a root" (turbopack-core/module_graph/mod.rs:662). Same bug class
|
||||
# the production Docker build worked around in PR #4052. Webpack starts
|
||||
# slower but compiles cleanly. Re-enable once upstream Turbopack ships a fix.
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
|
||||
# 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.
|
||||
@@ -176,12 +141,6 @@ OMNIROUTE_USE_TURBOPACK=1
|
||||
# hints in production logs.
|
||||
# OMNIROUTE_PROXY_FETCH_DEBUG=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
|
||||
|
||||
# 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
|
||||
@@ -195,13 +154,8 @@ OMNIROUTE_USE_TURBOPACK=1
|
||||
# Hostname/bind address for the Next.js server.
|
||||
# Used by: scripts/dev/run-next.mjs (HOST), Playwright runner (HOSTNAME).
|
||||
# Default: 0.0.0.0 (HOST) / 127.0.0.1 (HOSTNAME inside tests).
|
||||
# NOTE: Do NOT use `HOSTNAME` — it is a POSIX shell variable automatically set to
|
||||
# the machine name by bash/zsh. The .env loader cannot override it (first-wins
|
||||
# semantics). Use OMNIROUTE_SERVER_HOST instead for `omniroute serve`.
|
||||
# See: https://github.com/diegosouzapw/OmniRoute/issues/6194
|
||||
# HOST=0.0.0.0
|
||||
# HOSTNAME=127.0.0.1
|
||||
# OMNIROUTE_SERVER_HOST=0.0.0.0
|
||||
#HOST=0.0.0.0
|
||||
#HOSTNAME=127.0.0.1
|
||||
|
||||
# Environment mode — affects Next.js behavior, logging verbosity, and caching.
|
||||
# Values: production | development | Default: production
|
||||
@@ -213,17 +167,6 @@ NODE_ENV=production
|
||||
# gives the correct fix instructions (podman unshare chown vs sudo chown).
|
||||
CONTAINER_HOST=docker
|
||||
|
||||
# Container runtime override for skill sandboxing.
|
||||
# Used by: src/lib/skills/sandbox.ts + src/lib/skills/containerProvider.ts
|
||||
# Values: auto | docker | apple | wsl | orbstack | podman
|
||||
# - auto: OS-aware auto-detect (apple/orbstack on macOS, wsl on Windows, podman on Linux)
|
||||
# - apple: Apple Container (native OCI on macOS 26+)
|
||||
# - wsl: WSL Container CLI (wslc.exe on Windows)
|
||||
# - orbstack: OrbStack (high-perf Linux VM + docker shim on macOS)
|
||||
# - podman: Podman (rootless, daemonless)
|
||||
# - docker: Docker (default fallback)
|
||||
SKILLS_SANDBOX_RUNTIME=auto
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 4. SECURITY & AUTHENTICATION
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -288,33 +231,10 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# Default: 10485760 (10 MB)
|
||||
# MAX_BODY_SIZE_BYTES=10485760
|
||||
|
||||
# Heap-pressure-aware admission for POST /v1/chat/completions (#5152). A large
|
||||
# coding-agent "compact" body amplifies into hundreds of MB of transient JS objects
|
||||
# on the combo path; concurrent compacts can stack past the V8 heap ceiling and OOM
|
||||
# the process. These shed a LARGE body with 503 (Retry-After) only while the heap is
|
||||
# already under pressure — healthy heap admits every body untouched.
|
||||
# Used by: src/shared/middleware/chatBodyAdmission.ts
|
||||
# Bodies below this size skip the guard entirely (heap not even sampled). Default 262144 (256 KB).
|
||||
# OMNIROUTE_CHAT_LARGE_BODY_BYTES=262144
|
||||
# Hard cap — bodies above this are rejected with 413 before any clone/parse. Default 52428800 (50 MB).
|
||||
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
|
||||
# Shed large bodies once heapUsed/heap_size_limit reaches this ratio (0<r<1). Default 0.75.
|
||||
# OMNIROUTE_CHAT_HEAP_SHED_RATIO=0.75
|
||||
|
||||
# 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
|
||||
# instead of growing an unbounded string until the V8 heap is exhausted.
|
||||
# Used by: open-sse/handlers/chatCore/nonStreamingResponseBody.ts
|
||||
# Default: 67108864 (64 MB)
|
||||
# OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES=67108864
|
||||
|
||||
# CORS configuration — controls which cross-origin browser clients can call the API.
|
||||
# Used by: src/server/cors/origins.ts — sets Access-Control-Allow-Origin.
|
||||
# Same-origin dashboard requests behind a reverse proxy do not need CORS; they
|
||||
# use session-bound CSRF protection. No wildcard is sent unless CORS_ALLOW_ALL=true.
|
||||
# CORS_ALLOWED_ORIGINS=https://your-frontend.example.com
|
||||
# CORS_ORIGIN=https://your-frontend.example.com # legacy single-origin alias
|
||||
# CORS_ALLOW_ALL=false
|
||||
# CORS configuration — controls which origins can call the API.
|
||||
# Used by: Next.js middleware — sets Access-Control-Allow-Origin header.
|
||||
# Default: * (all origins) | Restrict for production security.
|
||||
# CORS_ORIGIN=https://your-domain.com
|
||||
|
||||
# Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, etc.).
|
||||
# REQUIRED for self-hosted providers: LM Studio, Ollama, vLLM, Llamafile, Triton, etc.
|
||||
@@ -322,12 +242,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# Default: false (blocked) | Set true to enable local providers.
|
||||
# OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true
|
||||
|
||||
# Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN).
|
||||
# Used by: src/shared/network/outboundUrlGuard.ts — scopes to the provider validation path and
|
||||
# still blocks cloud-metadata (169.254.169.254, metadata.google.internal). Default: true
|
||||
# (OmniRoute is local-first). Set false to enforce strict public-only blocking.
|
||||
# OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS=false
|
||||
|
||||
# Legacy alias toggling the SSRF guard. Used by: src/shared/network/outboundUrlGuard.ts
|
||||
# When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable.
|
||||
# OUTBOUND_SSRF_GUARD_ENABLED=true
|
||||
@@ -364,15 +278,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# PII_RESPONSE_SANITIZATION=false
|
||||
# PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response
|
||||
|
||||
# ── VS Code Tokenized-Route Context Sanitizer ──
|
||||
# Strips implicit active-editor context (editorContext/activeEditor/currentFile/
|
||||
# selection/openTabs…) from requests on the /v1/vscode/[token]/* routes before
|
||||
# forwarding upstream, and redacts the content of explicitly-attached sensitive
|
||||
# files (.env, private keys, kubeconfig, credentials/secrets). Explicit
|
||||
# attachments otherwise pass through. Secure-by-default: ON unless set to 0.
|
||||
# Used by: src/app/api/v1/vscode/contextSanitizer.ts
|
||||
# OMNIROUTE_VSCODE_SANITIZE_CONTEXT=1 # set to 0 to disable
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 6. TOOL & ROUTING POLICIES
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -404,7 +309,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# URLs used for internal sync jobs, OAuth callbacks, and cloud relay.
|
||||
|
||||
# Internal base URL — used by server-side sync jobs to call /api/sync/cloud.
|
||||
# Keep this as a loopback/container URL even when the app is publicly proxied.
|
||||
# Used by: src/lib/cloudSync.ts, src/lib/initCloudSync.ts
|
||||
# Default: http://localhost:20128
|
||||
BASE_URL=http://localhost:20128
|
||||
@@ -418,39 +322,27 @@ CLOUD_URL=
|
||||
# Default: 12000 (12 seconds)
|
||||
# CLOUD_SYNC_TIMEOUT_MS=12000
|
||||
|
||||
# Public-facing base URL — required for stable reverse proxy / OAuth callback setups.
|
||||
# Used by: OAuth redirect_uri computation, Dashboard UI links, and generated public URLs.
|
||||
# Set to your stable public URL when OAuth callbacks or generated browser links need a
|
||||
# canonical host behind nginx/Caddy (e.g., https://omniroute.example.com).
|
||||
# Public-facing base URL — CRITICAL for reverse proxy / OAuth callback setups.
|
||||
# Used by: OAuth redirect_uri computation, Dashboard UI links, cloud/model sync.
|
||||
# Set to your public URL when behind nginx/Caddy (e.g., https://omniroute.example.com).
|
||||
#
|
||||
# Dashboard display behavior: when this variable is unset, the dashboard
|
||||
# auto-detects the base URL shown in curl examples and CLI tool snippets
|
||||
# from window.location.origin (the host the user is browsing). Setting it
|
||||
# explicitly is only required when running behind a reverse proxy with a
|
||||
# different public hostname, or when OAuth callbacks / generated browser links must point
|
||||
# to a canonical URL. Authenticated dashboard writes use same-origin requests plus
|
||||
# session-bound CSRF protection and do not require a static public base URL.
|
||||
# different public hostname, or when OAuth callbacks must point to a
|
||||
# canonical URL.
|
||||
#
|
||||
# Default: http://localhost:20128
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:20128
|
||||
|
||||
# Browser-facing OmniRoute origin for generated assets in API responses.
|
||||
# Highest-priority public origin override; also used by non-dashboard public-origin validation.
|
||||
# Used by: chatgpt-web image generation cache URLs (/v1/chatgpt-web/image/<id>).
|
||||
# Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL
|
||||
# but the user's browser must fetch images from a LAN, tunnel, or public origin.
|
||||
# Do not include /v1; if included accidentally it will be normalized away.
|
||||
# OMNIROUTE_PUBLIC_BASE_URL=http://192.168.0.15:20128
|
||||
|
||||
# Absolute provider plugin manifest URL advertised to sidecar clients.
|
||||
# Used by: open-sse/config/providerPluginManifestUrl.ts. When unset, OmniRoute
|
||||
# derives the URL from request origin or HOST/PORT using OMNIROUTE_PUBLIC_PROTOCOL.
|
||||
# OMNIROUTE_PROVIDER_MANIFEST_URL=https://omniroute.example.com/api/v1/provider-plugin-manifest
|
||||
|
||||
# Protocol used when deriving provider plugin manifest URLs without a request origin.
|
||||
# Used by: open-sse/config/providerPluginManifestUrl.ts. Defaults to http.
|
||||
# OMNIROUTE_PUBLIC_PROTOCOL=http
|
||||
|
||||
# Max wait time for an async chatgpt-web image to land via the celsius
|
||||
# WebSocket, in milliseconds. Default 180000 (3 minutes). Increase during
|
||||
# upstream queue-deep windows ("Lots of people are creating images right now").
|
||||
@@ -462,30 +354,12 @@ NEXT_PUBLIC_BASE_URL=http://localhost:20128
|
||||
# is heavy and clients are racing the 30-minute TTL.
|
||||
# OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB=256
|
||||
|
||||
# Overall wait budget for a chatgpt-web GPT-5.5 Pro background-poll handoff,
|
||||
# in milliseconds. Default 1200000 (20 minutes). Pro reasoning runs are slow
|
||||
# and complete out-of-band, so OmniRoute polls until the answer lands or this
|
||||
# budget elapses. Raise it if Pro requests time out before finishing.
|
||||
# OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS=1200000
|
||||
|
||||
# Interval between chatgpt-web GPT-5.5 Pro background-poll attempts, in
|
||||
# milliseconds. Default 4000 (4 seconds). Lower for snappier completion at the
|
||||
# cost of more upstream polling; raise to reduce request volume.
|
||||
# OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS=4000
|
||||
|
||||
# Public cloud URL — client-side mirror of CLOUD_URL.
|
||||
NEXT_PUBLIC_CLOUD_URL=
|
||||
|
||||
# Legacy alias — fallback for NEXT_PUBLIC_BASE_URL in sync schedulers.
|
||||
# NEXT_PUBLIC_APP_URL=http://localhost:20128
|
||||
|
||||
# Advanced reverse-proxy trust mode for deriving public origin from Forwarded /
|
||||
# X-Forwarded-* headers when no explicit public base URL is set. Prefer setting
|
||||
# NEXT_PUBLIC_BASE_URL. Only enable if direct client access to OmniRoute is blocked
|
||||
# and your proxy strips/rebuilds incoming forwarded headers.
|
||||
# Values: true/loopback (trust loopback proxy peers), private/lan (also trust LAN peers).
|
||||
# OMNIROUTE_TRUST_PROXY=
|
||||
|
||||
# Public callback URL for asynchronous image/audio jobs (kie.ai, etc.).
|
||||
# Used by: open-sse/utils/kieTask.ts — overrides callbackUrlFromBaseUrl().
|
||||
# Honor order: KIE_CALLBACK_URL → OMNIROUTE_KIE_CALLBACK_URL → OMNIROUTE_PUBLIC_URL.
|
||||
@@ -493,49 +367,14 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
#OMNIROUTE_KIE_CALLBACK_URL=
|
||||
#OMNIROUTE_PUBLIC_URL=
|
||||
|
||||
# Headroom token-saver proxy URL. The dashboard lifecycle (api/headroom/*) spawns
|
||||
# a local headroom-ai CLI on loopback by default; override only to point at an
|
||||
# external Docker sidecar proxy. Defaults to http://localhost:8787 when unset.
|
||||
# Used by: src/lib/headroom/detect.ts.
|
||||
#HEADROOM_URL=http://localhost:8787
|
||||
|
||||
# Upstream quota endpoints used by the Usage page. Override only for
|
||||
# debugging or when routing through a corporate mirror. Used by:
|
||||
# open-sse/services/usage.ts.
|
||||
#OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/
|
||||
#OMNIROUTE_GEMINI_CLI_USAGE_URL=https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist
|
||||
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
|
||||
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
|
||||
# OpenCode Go has no public quota API — this has no default and stays
|
||||
# unset unless you explicitly opt in to a self-hosted/mirrored endpoint:
|
||||
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=
|
||||
#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace
|
||||
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings
|
||||
|
||||
# OpenCode Go dashboard quota scraping. Prefer configuring these per connection
|
||||
# in Dashboard → Providers → OpenCode Go. Env vars are useful for headless
|
||||
# deployments or shared server defaults. The cookie is sensitive.
|
||||
#OPENCODE_GO_WORKSPACE_ID=wrk_...
|
||||
#OMNIROUTE_OPENCODE_GO_WORKSPACE_ID=wrk_...
|
||||
#OPENCODE_GO_AUTH_COOKIE=auth=...
|
||||
#OMNIROUTE_OPENCODE_GO_AUTH_COOKIE=auth=...
|
||||
|
||||
# OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of
|
||||
# opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers.
|
||||
# When your clients don't already send them, set this to synthesize the CLI headers
|
||||
# (User-Agent, x-opencode-client, x-opencode-project, fresh request/session UUIDs) on
|
||||
# absent keys. OFF by default — forward-only is safer when clients already send them.
|
||||
# Values are overridable via OPENCODE_GO_USER_AGENT / OPENCODE_USER_AGENT / OPENCODE_CLIENT /
|
||||
# OPENCODE_PROJECT (defaults: opencode-cli/1.0.0 / cli / default).
|
||||
#OPENCODE_SYNTHESIZE_CLI_HEADERS=true
|
||||
#OPENCODE_USER_AGENT=opencode-cli/1.0.0
|
||||
#OPENCODE_CLIENT=cli
|
||||
#OPENCODE_PROJECT=default
|
||||
|
||||
# Ollama Cloud quota scraping. Prefer configuring this per connection in
|
||||
# Dashboard → Providers → Ollama Cloud. The cookie is sensitive.
|
||||
#OLLAMA_USAGE_COOKIE=__Secure-session=...
|
||||
#OLLAMA_CLOUD_USAGE_COOKIE=__Secure-session=...
|
||||
#OMNIROUTE_OLLAMA_USAGE_COOKIE=__Secure-session=...
|
||||
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 8. OUTBOUND PROXY (Upstream Provider Calls)
|
||||
@@ -554,19 +393,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# ALL_PROXY=socks5://127.0.0.1:7890
|
||||
# NO_PROXY=localhost,127.0.0.1
|
||||
|
||||
# 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.
|
||||
# Set to 1 only for legacy diagnostics. Values above 256 are capped.
|
||||
# OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS=32
|
||||
|
||||
# SOCKS5 handshake (connect) timeout in ms (default 10000, capped at 120000).
|
||||
# Raise it when a single residential gateway host is hit by high concurrency
|
||||
# (e.g. 100 simultaneous requests): the real SOCKS5 handshake can exceed 10s
|
||||
# under a saturated pool even though the proxy is reachable, which otherwise
|
||||
# surfaces as a false "[Proxy Fast-Fail] Proxy unreachable".
|
||||
# SOCKS_HANDSHAKE_TIMEOUT_MS=10000
|
||||
|
||||
# Proxy fail-open mode (default: false = fail-closed).
|
||||
# When false, a request whose assigned proxy fails to resolve is REFUSED rather than
|
||||
# falling back to a direct connection — prevents real-IP leaks in egress-controlled
|
||||
@@ -602,14 +428,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Allow OmniRoute to write CLI config files (token refresh, etc.).
|
||||
# CLI_ALLOW_CONFIG_WRITES=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
|
||||
# require CLI_ALLOW_CONFIG_WRITES (default on). Toggle from the CLI Code dashboard, or set here.
|
||||
# Leave unset to disable. (Feature flags — a DB/dashboard override takes precedence over env.)
|
||||
# OMNIROUTE_AUTO_SYNC_CODEX_PROFILES=true
|
||||
# OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES=true
|
||||
|
||||
# Override binary paths for individual CLI tools.
|
||||
# CLI_CLAUDE_BIN=claude
|
||||
# CLI_CODEX_BIN=codex
|
||||
@@ -620,8 +438,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# CLI_CONTINUE_BIN=cn
|
||||
# CLI_QODER_BIN=qoder
|
||||
# CLI_QWEN_BIN=qwen
|
||||
# CLI_AUGGIE_BIN=auggie
|
||||
# AUGGIE_BIN=auggie
|
||||
|
||||
# Override the Hermes Agent home directory (where OmniRoute reads/writes the
|
||||
# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets
|
||||
@@ -649,11 +465,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Legacy alias for OMNIROUTE_API_KEY.
|
||||
# ROUTER_API_KEY=
|
||||
|
||||
# CLI remote-mode context/profile for `omniroute` commands (overrides the active
|
||||
# context in the local contexts store). Equivalent to the `--context <name>` flag.
|
||||
# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode).
|
||||
# OMNIROUTE_CONTEXT=
|
||||
|
||||
# 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
|
||||
@@ -689,14 +500,6 @@ PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
|
||||
# to opt out (restores fully concurrent fetches). Default: 1500
|
||||
PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
|
||||
# Min interval (ms) between consecutive UPSTREAM quota fetches on the per-request
|
||||
# preflight/monitor path (e.g. Codex /wham/usage), complementing the bulk-sync
|
||||
# spacing above. Many accounts on one IP fetching quota in the same second can look
|
||||
# like automation to the upstream and get an OAuth token revoked (#6009). This gate
|
||||
# serializes genuine network calls (cache hits are unaffected). Set to 0 to disable.
|
||||
# Default: 250 (clamped 0..5000).
|
||||
# OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS=250
|
||||
|
||||
# Delay (ms) before refreshing provider limits after a real usage event (e.g. a
|
||||
# completed request). Gives the upstream quota API time to register the consumption
|
||||
# before the dashboard polls. Default: 5000
|
||||
@@ -712,16 +515,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# heuristic in instrumentation-node.ts. Default: unset (tests skip background).
|
||||
#OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS=1
|
||||
|
||||
# Proactive connection-cooldown recovery (#8): re-validates connections whose
|
||||
# transient `rate_limited_until` window has elapsed OUTSIDE the request hot path,
|
||||
# so the first request after a cooldown does not pay the probe latency. Lazy
|
||||
# recovery in getProviderCredentials still applies regardless. Used by:
|
||||
# src/lib/quota/connectionRecovery.ts.
|
||||
# Tick cadence (ms). Default 60000, floor 5000.
|
||||
# OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS=60000
|
||||
# Disable the proactive recovery scheduler entirely (default: false).
|
||||
# OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false
|
||||
|
||||
# Background job interval for budget reset checks (ms). Default: 600000 (10m).
|
||||
# Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000.
|
||||
#OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000
|
||||
@@ -765,34 +558,10 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
|
||||
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
|
||||
|
||||
# T02 stacked-pipeline engine circuit-breaker (OPT-IN, default off). When enabled, a compression
|
||||
# engine that throws repeatedly across requests is skipped (fail-open) for a cooldown.
|
||||
# Used by: open-sse/services/compression/pipelineEngineBreaker.ts.
|
||||
#COMPRESSION_PIPELINE_BREAKER_ENABLED=false # master switch (default false)
|
||||
#COMPRESSION_PIPELINE_BREAKER_THRESHOLD=3 # consecutive failures before the engine opens
|
||||
#COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS=30000 # ms the engine stays skipped before a probe
|
||||
|
||||
# T08/H8 — CCR retrieval-feedback ramp factor. Each prior retrieval of a stored block raises its
|
||||
# effective minChars linearly, so frequently-retrieved content is compressed progressively less
|
||||
# (>= 3 retrievals = never compressed). 1 disables the ramp (binary skip at the threshold only).
|
||||
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: 2.
|
||||
#COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR=2
|
||||
# T08/H5 — usage-observed prefix freeze (OPT-IN, default off). When enabled, a system prompt seen
|
||||
# >= THRESHOLD times is treated as a stable cacheable prefix and preserved from compression even
|
||||
# for providers the static cache-aware heuristic does not recognize (freeze = preserve, never
|
||||
# mutates). Used by: open-sse/services/compression/prefixFreeze.ts.
|
||||
#COMPRESSION_PREFIX_FREEZE_ENABLED=false # master switch (default false)
|
||||
#COMPRESSION_PREFIX_FREEZE_THRESHOLD=3 # observations before a prefix is frozen
|
||||
|
||||
# Skip the postinstall native-runtime warm-up (useful in CI / headless installs). Default: 0.
|
||||
# Used by: scripts/postinstall.mjs.
|
||||
#OMNIROUTE_SKIP_POSTINSTALL=0
|
||||
|
||||
# Operator-supplied JSON credentials for the offline compression-eval CLI
|
||||
# (parsed with JSON.parse; leave unset for a dry run). Developer tooling only.
|
||||
# Used by: scripts/compression-eval/index.ts. Default: {} (empty).
|
||||
#OMNIROUTE_EVAL_CREDENTIALS={}
|
||||
|
||||
# Skip the DB healthcheck entirely on startup (useful for short-lived tasks / tests).
|
||||
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to disable. Default: 0.
|
||||
#OMNIROUTE_SKIP_DB_HEALTHCHECK=0
|
||||
@@ -861,7 +630,7 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
|
||||
# Used by: open-sse/executors/theoldllm.ts. Default: 30000 (30s).
|
||||
# THEOLDLLM_NAV_TIMEOUT_MS=30000
|
||||
|
||||
# ── Gemini / Antigravity / Windsurf (all Google-based) ──
|
||||
# ── Gemini / Gemini CLI / Antigravity / Windsurf (all Google-based) ──
|
||||
# These providers ship public OAuth client_id/secret values (or Firebase Web
|
||||
# keys) embedded in their public CLIs/binaries. Defaults are baked into the
|
||||
# code via open-sse/utils/publicCreds.ts — leave the env vars unset to use
|
||||
@@ -870,6 +639,8 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
|
||||
#
|
||||
# GEMINI_OAUTH_CLIENT_ID=
|
||||
# GEMINI_OAUTH_CLIENT_SECRET=
|
||||
# GEMINI_CLI_OAUTH_CLIENT_ID=
|
||||
# GEMINI_CLI_OAUTH_CLIENT_SECRET=
|
||||
# ANTIGRAVITY_OAUTH_CLIENT_ID=
|
||||
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
||||
# WINDSURF_FIREBASE_API_KEY=
|
||||
@@ -886,7 +657,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# ── GitLab Duo ──
|
||||
# Register an OAuth app at: https://gitlab.com/-/profile/applications
|
||||
# Set redirect URI to: http://localhost:20128/callback (or your NEXT_PUBLIC_BASE_URL + /callback)
|
||||
# Required scopes: ai_features, read_user (matches GITLAB_DUO_CONFIG.scope in src/lib/oauth/constants/oauth.ts)
|
||||
# Required scopes: api, read_user, openid, profile, email
|
||||
# GITLAB_DUO_OAUTH_CLIENT_ID=***
|
||||
# GITLAB_DUO_OAUTH_CLIENT_SECRET=*** # optional — PKCE flow does not require a secret
|
||||
#
|
||||
@@ -931,8 +702,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# QODER_PERSONAL_ACCESS_TOKEN=
|
||||
# QODER_CLI_WORKSPACE=
|
||||
# OMNIROUTE_QODER_WORKSPACE=
|
||||
# Override the Qoder CLI config dir (isolated PAT session, avoids clobbering a browser login).
|
||||
# QODER_CLI_CONFIG_DIR=
|
||||
|
||||
# ── Blackbox Web validated-token override (issue #2252) ──
|
||||
# Used by: open-sse/executors/blackbox-web.ts. Blackbox `/api/chat` rejects
|
||||
@@ -959,7 +728,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# VISION_BRIDGE_API_KEY=
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
|
||||
# ⚠️ GOOGLE OAUTH (Antigravity, Gemini CLI) & OTHER PROVIDERS — REMOTE SERVERS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# The default Client IDs above ONLY work when OmniRoute runs on localhost.
|
||||
# For remote/VPS hosting (including Docker containers on remote servers):
|
||||
@@ -987,7 +756,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
|
||||
# Update these when providers release new CLI versions to avoid blocks.
|
||||
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.207 (external, cli)"
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)"
|
||||
|
||||
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
|
||||
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
|
||||
@@ -996,34 +765,23 @@ CLAUDE_USER_AGENT="claude-cli/2.1.207 (external, cli)"
|
||||
# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to
|
||||
# forward the original names verbatim (debugging only).
|
||||
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
|
||||
CODEX_USER_AGENT="codex-cli/0.144.1 (Windows 10.0.26200; x64)"
|
||||
GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0"
|
||||
CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)"
|
||||
GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1"
|
||||
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
|
||||
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
|
||||
# KIRO_VERIFY_FULL_CRC=false # opt-in: full per-frame message CRC validation on the Kiro event stream (debug corrupted streams; prelude CRC + TLS already protect framing)
|
||||
# Optional override for the Kiro social device-code OAuth clientId. Kiro's
|
||||
# device endpoint accepts any non-empty string and behaves like a User-Agent
|
||||
# rather than a secret. Only override if AWS ever starts enforcing this field.
|
||||
# Used by: src/lib/oauth/constants/oauth.ts (KIRO_CONFIG.socialClientId).
|
||||
# KIRO_OAUTH_CLIENT_ID=kiro-cli
|
||||
# Enable full per-frame message CRC validation for Kiro streams. Off by default
|
||||
# because it is O(frame bytes) on the main thread; use only for debugging
|
||||
# suspected corrupted-stream issues.
|
||||
# Used by: open-sse/executors/kiro.ts
|
||||
# KIRO_VERIFY_FULL_CRC=false
|
||||
QODER_USER_AGENT="Qoder-Cli"
|
||||
QWEN_USER_AGENT="QwenCode/0.19.3 (linux; x64)"
|
||||
QWEN_USER_AGENT="QwenCode/0.15.11 (linux; x64)"
|
||||
CURSOR_USER_AGENT="Cursor/3.4"
|
||||
GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
|
||||
# Override Codex client version sent in headers independently of the
|
||||
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
|
||||
# CODEX_CLIENT_VERSION=0.144.1
|
||||
|
||||
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
|
||||
# from the Codex Responses stream. These frames break the OpenAI SDK's
|
||||
# responses.stream() with a 502 "Controller is already closed". Off by default;
|
||||
# set to true/1/yes to enable. Used by: open-sse/executors/codex.ts.
|
||||
# OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS=true
|
||||
# CODEX_CLIENT_VERSION=0.132.0
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection)
|
||||
@@ -1101,22 +859,12 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
|
||||
# OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000
|
||||
|
||||
# ── Firecrawl web-fetch executor ──
|
||||
# Point at a self-hosted Firecrawl instance (defaults to the public cloud API).
|
||||
# When set to a non-cloud base URL, the API key becomes optional.
|
||||
# FIRECRAWL_BASE_URL=https://api.firecrawl.dev
|
||||
# FIRECRAWL_TIMEOUT_MS=30000 # Per-request timeout (default: 30000 = 30s)
|
||||
|
||||
# ── ChatGPT TLS sidecar (Firefox-fingerprinted client) ──
|
||||
# Used by: open-sse/services/chatgptTlsClient.ts — wire-level timeout for
|
||||
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
|
||||
# layered on top of it when the native library is wedged.
|
||||
# OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS=60000
|
||||
# OMNIROUTE_CHATGPT_TLS_GRACE_MS=10000
|
||||
# Max wait for the FIRST streamed byte from the ChatGPT TLS sidecar before the
|
||||
# request is aborted as a dead stream, in milliseconds. Default 30000 (30s).
|
||||
# Raise it if upstream cold-starts routinely exceed the window.
|
||||
# OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS=30000
|
||||
|
||||
# ── Claude TLS sidecar (Chromium-fingerprinted client) ──
|
||||
# Used by: open-sse/services/claudeTlsClient.ts — wire-level timeout for
|
||||
@@ -1160,30 +908,10 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD=2
|
||||
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS=15000
|
||||
|
||||
# ── Context-cache pin health gate ──
|
||||
# Used by: open-sse/services/combo.ts. When a context-cache pin points at a
|
||||
# provider that is durably unhealthy, the pin is dropped to allow failover.
|
||||
# PIN_DROP_BACKOFF_LEVEL gates how deep a connection's backoff must be before the
|
||||
# pin is considered durably unhealthy; PIN_DROP_GRACE_MS is the anti-flap window
|
||||
# that tolerates brief transient cooldowns before dropping the pin.
|
||||
# PIN_DROP_BACKOFF_LEVEL=2
|
||||
# PIN_DROP_GRACE_MS=20000
|
||||
|
||||
# ── Stream idle detection ──
|
||||
# STREAM_IDLE_TIMEOUT_MS=600000 # Max silence between SSE chunks (default: 600000)
|
||||
# # Extended-thinking models rarely pause >90s.
|
||||
# STREAM_READINESS_TIMEOUT_MS=80000 # Time to receive the first non-ping SSE event
|
||||
# STREAM_READINESS_MAX_TIMEOUT_MS=180000 # Cap for adaptive first-event extensions
|
||||
# # (large/tool-heavy/high-reasoning requests).
|
||||
# OMNIROUTE_AGENT_GOAL_POLICY_ENABLED=true # Kill-switch for the /goal heuristic below.
|
||||
# # Set to false to fully disable detection —
|
||||
# # readiness timeouts and stream recovery are
|
||||
# # never elevated by request body/headers when off.
|
||||
# OMNIROUTE_AGENT_GOAL_READINESS_MAX_TIMEOUT_MS=600000 # Auto cap for detected /goal agent runs
|
||||
# OMNIROUTE_AGENT_GOAL_STREAM_RECOVERY=true # Auto early stream recovery for /goal runs.
|
||||
# # NOTE: this can only ADD recovery on top of the
|
||||
# # operator default — it never overrides an explicit
|
||||
# # STREAM_RECOVERY_ENABLED / DB settings opt-out.
|
||||
|
||||
# ── TLS client (wreq-js fingerprint proxy) ──
|
||||
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
|
||||
@@ -1219,7 +947,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
APP_LOG_TO_FILE=true
|
||||
|
||||
# Path to the application log file.
|
||||
# Default: <DATA_DIR>/logs/application/app.log (DATA_DIR defaults to ~/.omniroute)
|
||||
# Default: logs/application/app.log (relative to project root / DATA_DIR)
|
||||
# APP_LOG_FILE_PATH=logs/application/app.log
|
||||
|
||||
# Maximum single log file size before rotation.
|
||||
@@ -1252,11 +980,6 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 100000
|
||||
# CALL_LOGS_TABLE_MAX_ROWS=100000
|
||||
|
||||
# Maximum age for orphaned active request log entries before the in-memory
|
||||
# pending-request reaper removes them. Accepts milliseconds.
|
||||
# Default: 3600000 (1 hour)
|
||||
# MAX_PENDING_REQUEST_AGE_MS=3600000
|
||||
|
||||
# Whether call log pipeline capture stores stream chunks when enabled in settings.
|
||||
# Only applies when call_log_pipeline_enabled=true.
|
||||
# Default: true
|
||||
@@ -1394,13 +1117,6 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 86400 (24 hours)
|
||||
# MODELS_DEV_SYNC_INTERVAL=86400
|
||||
|
||||
# Self-correcting context-window reconciler interval in seconds (feature 5004).
|
||||
# Pins provider-declared windows from /models discovery as auto:discovery overrides
|
||||
# when they diverge from the catalog. Set to 0 to disable. Never overwrites manual overrides.
|
||||
# Used by: src/lib/contextWindowResolver.ts
|
||||
# Default: 86400 (24 hours)
|
||||
# CONTEXT_WINDOW_RECONCILE_INTERVAL=86400
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 20. PROVIDER-SPECIFIC SETTINGS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1437,34 +1153,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/executors/cloudflare-ai.ts
|
||||
# CLOUDFLARE_ACCOUNT_ID=
|
||||
|
||||
# ── 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).
|
||||
# Used by: src/app/api/settings/proxy/deno-deploy/route.ts
|
||||
# DENO_DEPLOY_API_BASE=https://api.deno.com/v2
|
||||
|
||||
# Default Deno Deploy app name suggested in the "Deploy Relay" modal.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx
|
||||
# NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT=omniroute-deno-relay
|
||||
|
||||
# Set to "false" to hide the Deno Deploy relay option from the Proxy Pool tab.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx
|
||||
# NEXT_PUBLIC_DENO_RELAY_ENABLED=true
|
||||
|
||||
# ── Cloudflare Workers proxy relay (#4640 / 9router#1360) ──
|
||||
# Override the Cloudflare REST API base used by the proxy-pool relay deployer.
|
||||
# Default: https://api.cloudflare.com/client/v4 (omit unless mocking).
|
||||
# Used by: src/app/api/settings/proxy/cloudflare-deploy/route.ts
|
||||
# CLOUDFLARE_API_BASE=https://api.cloudflare.com/client/v4
|
||||
|
||||
# Default worker project name suggested in the "Deploy Relay" modal.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx
|
||||
# NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT=omniroute-relay
|
||||
|
||||
# Set to "false" to hide the Cloudflare Workers relay option from the Proxy Pool tab.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx
|
||||
# NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED=true
|
||||
|
||||
# ── Cloudflare Tunnel (cloudflared) ──
|
||||
# Custom path to cloudflared binary for tunnel management.
|
||||
# Used by: src/lib/cloudflaredTunnel.ts
|
||||
@@ -1512,13 +1200,6 @@ APP_LOG_TO_FILE=true
|
||||
# CLIPROXYAPI_PORT=5544
|
||||
# CLIPROXYAPI_CONFIG_DIR=~/.cli-proxy-api
|
||||
|
||||
# ── Mux embedded service ──
|
||||
# Override the port where the embedded Mux (coder/mux) agent-orchestration
|
||||
# daemon listens. Always bound to 127.0.0.1 — never configurable to 0.0.0.0.
|
||||
# Rarely needed — defaults to 8322.
|
||||
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
|
||||
# MUX_SERVICE_PORT=8322
|
||||
|
||||
# ── Local hostnames (Docker networking) ──
|
||||
# Comma-separated additional hostnames treated as "local" for provider routing.
|
||||
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
|
||||
@@ -1533,38 +1214,9 @@ APP_LOG_TO_FILE=true
|
||||
# Timeout for fast-fail health checks (ms). Default: 2000
|
||||
# PROXY_FAST_FAIL_TIMEOUT_MS=2000
|
||||
|
||||
# Time window (hours) for calculating the average latency of candidate proxies
|
||||
# in the latency-optimized pool strategy. Default: 3
|
||||
# Used by: src/lib/db/proxies.ts
|
||||
# PROXY_LATENCY_WINDOW_HOURS=3
|
||||
|
||||
# Health check result cache TTL (ms). Default: 30000 (30s)
|
||||
# PROXY_HEALTH_CACHE_TTL_MS=30000
|
||||
|
||||
# Unhealthy health check result cache TTL (ms). Default: 2000 (2s)
|
||||
# Keeps transient fast-fail timeouts from poisoning a proxy for the full
|
||||
# healthy-result cache window under high concurrency.
|
||||
# PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS=2000
|
||||
|
||||
# Background proxy health scheduler (src/lib/proxyHealth/scheduler.ts).
|
||||
# Periodically probes every registered proxy and (optionally) removes dead ones.
|
||||
# Set "false" to disable the scheduler entirely. Default: enabled.
|
||||
# PROXY_HEALTH_ENABLED=true
|
||||
# Sweep interval in ms (minimum 60000). Default: 600000 (10min).
|
||||
# PROXY_HEALTH_INTERVAL_MS=600000
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
# assigned proxy; #6246). Set "true" to restore the legacy test-and-set behaviour.
|
||||
# PROXY_HEALTH_AUTO_DEACTIVATE=false
|
||||
|
||||
# Allow OAuth and provider validation flows to bypass a pinned proxy and connect
|
||||
# directly when proxy reachability pre-checks fail. Default: false.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
@@ -1598,25 +1250,6 @@ APP_LOG_TO_FILE=true
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# PROVIDER_COOLDOWN_ENABLED=true
|
||||
|
||||
# Transparent stream recovery (free-claude-code port). When enabled, the opening SSE
|
||||
# window is briefly held (up to STREAM_RECOVERY.HOLDBACK_MS) so an upstream truncation
|
||||
# before any byte reaches the client can be retried invisibly. Opt-in: holding the
|
||||
# window adds up to that much time-to-first-token latency on every stream, so it is
|
||||
# OFF by default. Seeds ResilienceSettings.streamRecovery.enabled.
|
||||
# Used by: open-sse/services/streamRecovery.ts, open-sse/handlers/chatCore.ts
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# STREAM_RECOVERY_ENABLED=true
|
||||
|
||||
# Mid-stream continuation (Fase 4.4): when an upstream stream truncates AFTER bytes
|
||||
# already reached the client, re-request with the partial text as an assistant prefill
|
||||
# and stitch the missing suffix (plain-text OpenAI-compatible streams only; never with a
|
||||
# tool call in flight). OFF by default — the recovered tail arrives as one burst, not
|
||||
# token-by-token. Independent of STREAM_RECOVERY_ENABLED (different risk profile).
|
||||
# Seeds ResilienceSettings.streamRecovery.continueMidStream.
|
||||
# Used by: open-sse/services/streamRecovery.ts, open-sse/handlers/chatCore.ts
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# STREAM_RECOVERY_MIDSTREAM_ENABLED=true
|
||||
|
||||
# Stagger interval (ms) between provider token healthchecks at startup.
|
||||
# Used by: src/lib/tokenHealthCheck.ts. Default: 3000.
|
||||
# HEALTHCHECK_STAGGER_MS=3000
|
||||
@@ -1656,19 +1289,10 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/utils/cursorImages.ts.
|
||||
# CURSOR_IMAGE_FETCH_TIMEOUT_MS=15000
|
||||
|
||||
# Cursor state DB path override (for IDE cursor version detection).
|
||||
# Cursor state DB path override (for cursor version detection).
|
||||
# Used by: open-sse/utils/cursorVersionDetector.ts. Default: probed automatically.
|
||||
# CURSOR_STATE_DB_PATH=
|
||||
|
||||
# Cursor Agent CLI build id for AgentService/Run impersonation (YYYY.MM.DD-<hash>).
|
||||
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: detect local install, else pin.
|
||||
# CURSOR_AGENT_CLI_VERSION=2026.07.08-0c04a8a
|
||||
|
||||
# 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.
|
||||
# CURSOR_DATA_DIR=
|
||||
|
||||
# Direct Cursor bearer token used by scripts/ad-hoc/cursor-tap.cjs (developer tooling).
|
||||
# CURSOR_TOKEN=
|
||||
|
||||
@@ -1755,20 +1379,6 @@ APP_LOG_TO_FILE=true
|
||||
# Routing-decision log verbosity: 0 silences, higher values log more bypass/route
|
||||
# decisions (src/mitm/server.cjs, _internal/bypass.cjs).
|
||||
# MITM_VERBOSE=1
|
||||
# Strip the leading `sudo` from MITM cert-trust commands (src/mitm/systemCommands.ts) —
|
||||
# for root-less / user-namespaced deployments (e.g. rootless Docker/Podman)
|
||||
# where the operator trusts the CA manually (e.g. via Node's extra-CA-certs mechanism).
|
||||
# OMNIROUTE_NO_SUDO=0
|
||||
|
||||
# ── Test/CI-only guards (never needed in production) ──
|
||||
# Set automatically by tests/_setup/isolateDataDir.ts and the CI workflows: the
|
||||
# test suite must NEVER mutate the OS trust store (a fake test PEM installed via
|
||||
# update-ca-certificates broke all system TLS on a persistent runner, 2026-07-05).
|
||||
# OMNIROUTE_SKIP_SYSTEM_TRUST=1
|
||||
# check-changelog-integrity.mjs (anti CHANGELOG-eat gate): explicit base ref
|
||||
# override, and the justified-removal escape hatch for intentional bullet removals.
|
||||
# CHANGELOG_BASE_REF=origin/release/v0.0.0
|
||||
# ALLOW_CHANGELOG_REMOVALS=1
|
||||
|
||||
# ── 1Proxy egress pool ──
|
||||
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
|
||||
@@ -1799,15 +1409,6 @@ APP_LOG_TO_FILE=true
|
||||
# FREE_PROXY_IPLOCATE_ENABLED=false
|
||||
# FREE_PROXY_IPLOCATE_BASE_URL=https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols
|
||||
|
||||
# ── Free Proxy Pool (Webshare source) ──
|
||||
# Used by: src/lib/freeProxyProviders/webshare.ts
|
||||
# Paid, per-account proxy list — requires FREE_PROXY_WEBSHARE_API_KEY to activate,
|
||||
# regardless of FREE_PROXY_WEBSHARE_ENABLED.
|
||||
# FREE_PROXY_WEBSHARE_ENABLED=true
|
||||
# FREE_PROXY_WEBSHARE_API_KEY=
|
||||
# FREE_PROXY_WEBSHARE_API_URL=https://proxy.webshare.io/api/v2/proxy/list/
|
||||
# FREE_PROXY_WEBSHARE_MAX=500
|
||||
|
||||
# ── Vercel Relay ──
|
||||
# Used by: src/app/api/settings/proxy/vercel-deploy/route.ts
|
||||
# Hides the "Deploy Relay" button when set to false.
|
||||
@@ -1821,10 +1422,6 @@ APP_LOG_TO_FILE=true
|
||||
# dashboard's tunnel manager. Used by: src/lib/tailscaleTunnel.ts.
|
||||
# TAILSCALE_BIN=/usr/local/bin/tailscale
|
||||
# TAILSCALED_BIN=/usr/local/bin/tailscaled
|
||||
# Pre-shared Tailscale auth key for non-interactive / headless `tailscale up`
|
||||
# (passed via --auth-key=). When unset, login falls back to the interactive
|
||||
# browser auth URL. Used by: src/lib/tailscaleTunnel.ts.
|
||||
# TAILSCALE_AUTHKEY=
|
||||
|
||||
# ── Ngrok tunnel ──
|
||||
# Used by: src/lib/ngrokTunnel.ts — authenticates outbound tunnels.
|
||||
@@ -1850,15 +1447,6 @@ APP_LOG_TO_FILE=true
|
||||
# SKILLS_SANDBOX_NETWORK_ENABLED=0
|
||||
# SKILLS_ALLOWED_SANDBOX_IMAGES=
|
||||
|
||||
# Container runtime used by the skill sandbox. Accepted values:
|
||||
# auto — pick the best installed runtime per host OS (default)
|
||||
# docker — Docker Engine / Docker Desktop
|
||||
# apple — Apple Container (macOS native, micro-VM)
|
||||
# wsl — WSL Container (Windows native via wslc.exe)
|
||||
# orbstack — OrbStack (high-perf Linux VM + docker shim on macOS)
|
||||
# podman — Podman (rootless, daemonless)
|
||||
# SKILLS_SANDBOX_RUNTIME=auto
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 25. TEST & E2E
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1904,7 +1492,7 @@ APP_LOG_TO_FILE=true
|
||||
# OMNIROUTE_TRANSLATION_API_URL=
|
||||
# Bearer token for the translation backend (NEVER commit a real key here).
|
||||
# OMNIROUTE_TRANSLATION_API_KEY=
|
||||
# Model id, e.g. gpt-4o-mini or cx/gpt-5.6-sol.
|
||||
# Model id, e.g. gpt-4o-mini or cx/gpt-5.4-mini.
|
||||
# OMNIROUTE_TRANSLATION_MODEL=gpt-4o-mini
|
||||
# Per-request timeout in milliseconds (default 60000).
|
||||
# OMNIROUTE_TRANSLATION_TIMEOUT_MS=60000
|
||||
@@ -1959,11 +1547,6 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
|
||||
# MEMORY_VEC_TOP_K=20 # default top-K for vector search
|
||||
# MEMORY_RRF_K=60 # RRF k constant (sqlite-vec hybrid recipe)
|
||||
# HF_HUB_ENDPOINT=https://huggingface.co # override Hugging Face Hub base URL for static potion downloads
|
||||
# TV6 typed memory decay (OPT-IN, default off — the sweep DELETES decayed memories)
|
||||
# MEMORY_TYPED_DECAY_ENABLED=false # master switch for the destructive sweep (default off)
|
||||
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
|
||||
# MEMORY_TYPED_DECAY_ACCESS_IMMUNITY=3 # access_count >= N → immune; 0 disables access immunity
|
||||
# MEMORY_TYPED_DECAY_SWEEP_INTERVAL=0 # periodic sweep interval (seconds); 0 = no periodic sweep
|
||||
# AgentBridge + Traffic Inspector (Group A)
|
||||
|
||||
# AgentBridge
|
||||
@@ -1984,17 +1567,7 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
|
||||
# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
|
||||
# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
|
||||
# STATUS_SOFT_DEPRIORITIZE_FACTOR=0.5 # 0..1; multiplicador do score p/ provider esgotado (credits_exhausted/rate_limited) quando preflight cutoff OFF (#4540)
|
||||
# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos
|
||||
# QUOTA_PREFLIGHT_CUTOFF_ENABLED=false # opt-in (default OFF): hard quota cutoff drops low-quota candidates before auto-routing scoring
|
||||
|
||||
# ─── Auto-Combo tier filter (#4517) ───────────────────────────────────────
|
||||
# When an `auto/<category>:free` (or any `:<tier>`) request matches NO connected
|
||||
# candidates, OmniRoute returns an EMPTY pool by default — so `:free` really means
|
||||
# "free tier only" and a paid model is never picked just because no free provider is
|
||||
# connected. Set this to `true`/`1` to restore the legacy behavior of falling back to
|
||||
# the full (unfiltered) pool with a warning. Source: open-sse/services/autoCombo/virtualFactory.ts
|
||||
# OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=false
|
||||
|
||||
# ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ───
|
||||
# Base URL of the OmniRoute instance to query for /v1/models when regenerating
|
||||
@@ -2007,129 +1580,3 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by:
|
||||
# scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY.
|
||||
# OPENCODE_API_KEY=
|
||||
|
||||
# ─── Bifrost Go sidecar (PR-4 in #3932) ──────────────────────────────────────
|
||||
# Master kill switch for the bifrost sidecar proxy. When set to 0, the
|
||||
# /api/v1/relay/chat/completions/bifrost route returns 503 with the
|
||||
# X-Bifrost-Killswitch header and the operator is bounced to the TS path.
|
||||
# Use this to disable the sidecar without redeploying (e.g. during a
|
||||
# tier-1 router incident or a key rotation). Default: 1 (sidecar active).
|
||||
# BIFROST_ENABLED=1
|
||||
# When BIFROST_BASE_URL is set, /api/v1/relay/chat/completions/bifrost routes
|
||||
# traffic to the Go gateway instead of the TS relay handler, removing TS from
|
||||
# the hot path. Auth/rate-limit/injection-guard stay in the route (security not
|
||||
# duplicated). Falls back to TS path via X-Bifrost-Fallback header on
|
||||
# timeout/failure. See bin/omniroute for the local-redis companion.
|
||||
# BIFROST_BASE_URL=
|
||||
# Port the supervised Bifrost embedded service binds to (127.0.0.1:<port>), read by
|
||||
# src/lib/services/bootstrap.ts when OmniRoute manages the Bifrost sidecar lifecycle.
|
||||
# Default: 8080.
|
||||
# BIFROST_PORT=8080
|
||||
# API key for the Bifrost gateway (sent as Authorization: Bearer ...). If
|
||||
# unset, the route expects the request to carry a valid OmniRoute API key;
|
||||
# this key is for gateway-side auth only.
|
||||
# BIFROST_API_KEY=
|
||||
# When true, the Bifrost sidecar route streams responses back via SSE through
|
||||
# the gateway rather than the TS streaming executor. Default: true (when
|
||||
# BIFROST_BASE_URL is set).
|
||||
# BIFROST_STREAMING_ENABLED=
|
||||
# Per-request timeout when proxying to the Bifrost gateway. Default: 30000 (30s).
|
||||
# BIFROST_TIMEOUT_MS=
|
||||
# Alias for BIFROST_API_KEY (used by scripts that read the env via
|
||||
# OMNIROUTE_*). BIFROST_API_KEY takes precedence when both are set.
|
||||
# OMNIROUTE_BIFROST_KEY=
|
||||
# Relay backend selection for the OpenAI-compatible relay endpoint:
|
||||
# ts | bifrost | auto. "ts" (default when Bifrost is not configured) uses the
|
||||
# TypeScript relay; "auto" selects Bifrost when BIFROST_BASE_URL is set (and
|
||||
# BIFROST_ENABLED != 0) and falls back to TS if the sidecar is unreachable;
|
||||
# "bifrost" forces Bifrost (strict — no TS fallback). Auth, rate limits,
|
||||
# injection guard and model allowlists always run in the Next route first.
|
||||
# RELAY_ROUTING_BACKEND is an accepted alias. Responses carry X-Routing-Backend
|
||||
# and X-Routing-Fallback.
|
||||
# OMNIROUTE_RELAY_BACKEND=
|
||||
# RELAY_ROUTING_BACKEND=
|
||||
# Cooldown (ms) after a Bifrost sidecar hop fails in "auto" mode before the relay
|
||||
# re-attempts the sidecar; it goes straight to the TS path while the cooldown lasts.
|
||||
# 0 disables. Default 5000. Only applies when OMNIROUTE_RELAY_BACKEND=auto.
|
||||
# OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS=
|
||||
# Opt-in native HTTPS/TLS for `omniroute serve` (equivalent to --tls-cert /
|
||||
# --tls-key). Provide BOTH a PEM certificate and its private key and the
|
||||
# standalone server terminates TLS on the same listener (wss:// works
|
||||
# unchanged). With neither set the server stays plain HTTP; providing only one
|
||||
# (or an unreadable path) logs a warning and stays HTTP (never half-enables).
|
||||
# OMNIROUTE_TLS_CERT=
|
||||
# OMNIROUTE_TLS_KEY=
|
||||
|
||||
# ─── 1-click local service launchers (PR-3 in #3932) ────────────────────────
|
||||
# Master switch for /api/local/* routes. When unset or "0", all /api/local/*
|
||||
# routes return 503 in production. Default: 0. Must be "1" in non-loopback
|
||||
# deploys to enable the Redis launcher and similar 1-click local service
|
||||
# starters. Belt-and-suspenders with the isLocalOnlyPath() route-guard
|
||||
# classification (LOCAL_ONLY_API_PREFIXES in src/server/authz/routeGuard.ts).
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=
|
||||
# Bearer token for /api/local/* callers that aren't on loopback (e.g. the
|
||||
# desktop app). When set, requests from non-loopback IPs must carry
|
||||
# Authorization: Bearer <token>. Required when
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1 in non-loopback deployments. Default:
|
||||
# unset (loopback-only).
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_TOKEN=
|
||||
# Container name for the 1-click Redis launcher (`omniroute redis up`).
|
||||
# Default: omniroute-redis. Used by bin/cli/commands/redis.mjs and the
|
||||
# RedisLauncherPanel.
|
||||
# OMNIROUTE_REDIS_CONTAINER_NAME=
|
||||
# Host port for the 1-click Redis launcher. Default: 6379. Bump if the host
|
||||
# already binds 6379. The container's internal port stays 6379.
|
||||
# OMNIROUTE_REDIS_HOST_PORT=
|
||||
# Redis image used by the 1-click Redis launcher. Default: redis:7-alpine.
|
||||
# Override to redis:8-alpine or a private registry mirror as needed.
|
||||
# OMNIROUTE_REDIS_IMAGE=
|
||||
|
||||
# ── Cluster Profile: Qdrant Vector Memory (opt-in via `docker compose --profile memory up`) ──
|
||||
# Qdrant is an OPTIONAL sidecar for deployments that need cosine-distance vector
|
||||
# search at >1M embeddings. The default vector store is sqlite-vec
|
||||
# (src/lib/memory/vectorStore.ts:108); flip this profile on only if you hit the
|
||||
# sqlite-vec ceiling or want persistent cross-replica vector state. See
|
||||
# docs/architecture/cluster-decisions.md § "Qdrant (memory profile)".
|
||||
# QDRANT_HOST=qdrant
|
||||
# QDRANT_PORT=6333
|
||||
# QDRANT_GRPC_PORT=6334
|
||||
# QDRANT_API_KEY=
|
||||
# QDRANT_COLLECTION=omniroute-memory
|
||||
# QDRANT_EMBEDDING_MODEL=text-embedding-3-small
|
||||
# QDRANT_VECTOR_SIZE=1536
|
||||
# QDRANT_HNSW_EF_CONSTRUCT=128
|
||||
|
||||
# ── Cluster Profile: Bifrost Tier-1 Router (opt-in via `docker compose --profile bifrost up`) ──
|
||||
# Bifrost is an OPTIONAL Go-based Tier-1 router that handles the upstream-provider
|
||||
# multiplexing layer. Default: OmniRoute's open-sse/executors/bifrost.ts in-process
|
||||
# executor handles routing directly. Flip this profile on only if you want the
|
||||
# gateway as a separate sidecar (helps in 3+ replica deployments where you want
|
||||
# provider rotation centralised). See docs/architecture/cluster-decisions.md §
|
||||
# "Bifrost (bifrost profile)".
|
||||
# Set OMNIROUTE_RELAY_BACKEND=auto to use this sidecar when healthy, or
|
||||
# OMNIROUTE_RELAY_BACKEND=bifrost to require it without TS fallback.
|
||||
# BIFROST_BASE_URL=http://bifrost:8080
|
||||
# BIFROST_API_KEY=
|
||||
# BIFROST_STREAMING_ENABLED=true
|
||||
# BIFROST_TIMEOUT_MS=30000
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Account rotation config (operator-managed; consumed by open-sse/services/rotationConfig.ts)
|
||||
# Lets a supervising front-end mirror its rotation rules onto the backend's account-fallback
|
||||
# engine. All optional; defaults preserve the historical behavior.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# OMNIROUTE_ROTATION_ENABLED=true
|
||||
# OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS=0
|
||||
# OMNIROUTE_ROTATION_DISABLE_TAG_WITHOUT_RESET=true
|
||||
# OMNIROUTE_ROTATE_ON_429=true
|
||||
# OMNIROUTE_ROTATE_429_THRESHOLD=1
|
||||
# OMNIROUTE_ROTATE_429_WINDOW_SECONDS=120
|
||||
# OMNIROUTE_ROTATE_ON_500=true
|
||||
# OMNIROUTE_ROTATE_500_THRESHOLD=1
|
||||
# OMNIROUTE_ROTATE_500_WINDOW_SECONDS=120
|
||||
# OMNIROUTE_ROTATE_ON_502=true
|
||||
# OMNIROUTE_ROTATE_502_THRESHOLD=1
|
||||
# OMNIROUTE_ROTATE_502_WINDOW_SECONDS=120
|
||||
# OMNIROUTE_ROTATE_ON_400=false
|
||||
# OMNIROUTE_ROTATE_400_THRESHOLD=1
|
||||
# OMNIROUTE_ROTATE_400_WINDOW_SECONDS=120
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Homologação E2E real — copie para .env.homolog (NUNCA commitar o real)
|
||||
HOMOLOG_BASE_URL=http://192.168.0.15:20128
|
||||
# Senha de management do dashboard da VPS (a mesma do /login)
|
||||
HOMOLOG_ADMIN_PASSWORD=
|
||||
# Deixe vazio: a suíte cria uma API key efêmera via admin e revoga no fim.
|
||||
# Só preencha para depurar uma camada isolada com uma key fixa.
|
||||
HOMOLOG_API_KEY=
|
||||
# Tier crítico (chat real, max_tokens=5). Demais providers: só validação de catálogo.
|
||||
HOMOLOG_CRITICAL_PROVIDERS=openai,anthropic,gemini,codex,grok,glm,deepseek,openrouter
|
||||
29
.github/actions/npm-ci-retry/action.yml
vendored
29
.github/actions/npm-ci-retry/action.yml
vendored
@@ -1,29 +0,0 @@
|
||||
name: npm ci with retry
|
||||
description: Run npm ci with retries for transient registry/network failures.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
max_attempts=3
|
||||
delay_seconds=20
|
||||
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "npm ci attempt $attempt/$max_attempts after transient failure"
|
||||
fi
|
||||
|
||||
if npm ci; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit_code=$?
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
exit "$exit_code"
|
||||
fi
|
||||
|
||||
sleep "$delay_seconds"
|
||||
delay_seconds=$((delay_seconds * 2))
|
||||
done
|
||||
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
@@ -24,15 +24,6 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "eslint-config-next"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# typescript majors are peer-blocked by typescript-eslint, which pins a hard
|
||||
# upper bound (8.64.0 → peerDependencies.typescript ">=4.8.4 <6.1.0"). A TS 7
|
||||
# bump therefore violates the peer and takes down the whole toolchain at once —
|
||||
# #7068 grouped it with 6 harmless bumps and turned Build + Lint + Quality Ratchet
|
||||
# + Unit (6/8, 8/8) + Integration (1/2, 2/2) + dast-smoke red in one shot, blocking
|
||||
# the innocuous updates riding along with it. Un-ignore once typescript-eslint
|
||||
# widens the peer, and migrate TS majors intentionally (own PR, own CI run).
|
||||
- dependency-name: "typescript"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API).
|
||||
# scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it
|
||||
# parses jscpd-report.json against a frozen baseline). A v5 major would break
|
||||
|
||||
2
.github/workflows/build-fork.yml
vendored
2
.github/workflows/build-fork.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
705
.github/workflows/ci.yml
vendored
705
.github/workflows/ci.yml
vendored
File diff suppressed because it is too large
Load Diff
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -19,13 +19,13 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
- uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
- uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
11
.github/workflows/dast-smoke.yml
vendored
11
.github/workflows/dast-smoke.yml
vendored
@@ -10,15 +10,12 @@ jobs:
|
||||
# ADVISORY while this new gate matures (repo convention: advisory -> blocking).
|
||||
# Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs.
|
||||
continue-on-error: true
|
||||
# Build CLI bundle alone varies 6-11min on GitHub-hosted runners (3 consecutive
|
||||
# timeouts observed on 2026-07-14 with the old 12min cap killing schemathesis
|
||||
# mid-run) — 25min leaves real headroom for the actual DAST steps.
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -39,13 +36,13 @@ jobs:
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install schemathesis
|
||||
- name: Schemathesis smoke (high-risk endpoints, blocking)
|
||||
run: |
|
||||
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
|
||||
schemathesis run docs/reference/openapi.yaml --url http://localhost:20128 \
|
||||
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
|
||||
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
|
||||
--request-timeout 20 --suppress-health-check all --no-color
|
||||
|
||||
31
.github/workflows/docker-publish.yml
vendored
31
.github/workflows/docker-publish.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
@@ -98,13 +98,11 @@ jobs:
|
||||
PROMOTE="${PROMOTE_INPUT:-false}"
|
||||
else
|
||||
git fetch --tags --quiet || true
|
||||
# Decide via the extracted helper, which folds VERSION into the
|
||||
# candidate set so the result is independent of git-tag sync timing
|
||||
# on `release` events (#5301). Without that, the freshly-created tag
|
||||
# is often not yet visible here and :latest stays a release behind.
|
||||
PROMOTE=$(git tag -l 'v[0-9]*' | bash scripts/ci/should-promote-latest.sh "$VERSION")
|
||||
if [ "$PROMOTE" != "true" ]; then
|
||||
echo "Version $VERSION is not the highest stable semver. Not promoting :latest."
|
||||
HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "")
|
||||
if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then
|
||||
PROMOTE="true"
|
||||
else
|
||||
echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-<none>}). Not promoting :latest."
|
||||
fi
|
||||
fi
|
||||
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
|
||||
@@ -145,7 +143,7 @@ jobs:
|
||||
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
@@ -245,7 +243,7 @@ jobs:
|
||||
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
@@ -346,15 +344,6 @@ jobs:
|
||||
|
||||
# Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but
|
||||
# never blocks (exit-code 0). The blocking gate below narrows to CRITICAL.
|
||||
#
|
||||
# ignore-unfixed mirrors the blocking gate: the Security tab must surface only
|
||||
# ACTIONABLE vulnerabilities — ones with a published fix we can pull by rebuilding
|
||||
# on a patched base or bumping the dep. Without it the advisory upload floods the
|
||||
# tab with unfixable base-image OS CVEs (Debian trixie packages with no upstream
|
||||
# patch yet, overwhelmingly local-only and not reachable from the proxy request
|
||||
# surface), which is noise an operator cannot act on. trivyignores points at the
|
||||
# repo-root .trivyignore so accepted-risk fixable CVEs have one auditable home.
|
||||
# See docs/security/SUPPLY_CHAIN.md.
|
||||
- name: Trivy image scan (SARIF, advisory)
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
@@ -364,8 +353,6 @@ jobs:
|
||||
format: sarif
|
||||
output: trivy-results.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
ignore-unfixed: true
|
||||
trivyignores: .trivyignore
|
||||
exit-code: "0"
|
||||
|
||||
# BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the
|
||||
@@ -390,7 +377,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
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
15
.github/workflows/electron-release.yml
vendored
15
.github/workflows/electron-release.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
version: ${{ steps.validate.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
deb_ext: .deb
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v6.1.0
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
|
||||
@@ -201,12 +201,6 @@ jobs:
|
||||
[ -f "$file" ] && cp "$file" "../../release-assets/OmniRoute.exe" && break
|
||||
done
|
||||
fi
|
||||
# electron-updater manifests (latest.yml / latest-mac.yml / latest-linux.yml)
|
||||
# must be published alongside the installers, or autoUpdater fails with
|
||||
# "Cannot find latest.yml in the latest release artifacts" (#6766).
|
||||
for file in latest*.yml; do
|
||||
[ -f "$file" ] && cp "$file" ../../release-assets/
|
||||
done
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -222,7 +216,7 @@ jobs:
|
||||
contents: write # softprops/action-gh-release creates the GitHub Release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -269,7 +263,6 @@ jobs:
|
||||
release-assets/*.AppImage
|
||||
release-assets/*.deb
|
||||
release-assets/*.blockmap
|
||||
release-assets/*.yml
|
||||
release-assets/*.source.tar.gz
|
||||
release-assets/*.source.zip
|
||||
env:
|
||||
|
||||
63
.github/workflows/mutation-redundancy.yml
vendored
63
.github/workflows/mutation-redundancy.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: Mutation Redundancy (disableBail, on-demand)
|
||||
# One-off measurement to UNBLOCK R1 (test-redundancy prune). The nightly mutation run
|
||||
# (nightly-mutation.yml) bails on the first kill, so `killedBy` lists only the FIRST
|
||||
# killer — 🟠 redundant is understated and 🟢 unique overstated (see the caveat in
|
||||
# scripts/quality/mutation-radiography.mjs). This workflow re-runs the SAME combo +
|
||||
# chatCore leaf batches with stryker.disablebail.json (disableBail:true, incremental:false)
|
||||
# so `killedBy` lists EVERY killer. Feed the uploaded reports to
|
||||
# `node scripts/quality/mutation-radiography.mjs --candidates mutation-nobail-*/mutation.json`
|
||||
# to get the accurate R1 prune-candidate list (🔴 empty ∪ 🟠 redundant) for human review.
|
||||
#
|
||||
# Batches mirror the nightly's leaf decomposition (d/e/f/g/h/i) rather than 2 mega-batches:
|
||||
# disableBail is MORE expensive than bail (it never stops early), and Stryker only writes
|
||||
# mutation.json on a SUCCESSFUL finish — a batch cancelled at the cap produces NO data — so
|
||||
# smaller batches each fit the 300min headroom and run in parallel. auth/accountFallback and
|
||||
# the security quartet are out of scope: R1 targets the combo/chatCore leaves.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stryker-nobail:
|
||||
name: Stryker disableBail (batch ${{ matrix.batch.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
batch:
|
||||
- name: d
|
||||
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
|
||||
- name: e
|
||||
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
|
||||
- name: f
|
||||
mutate: "open-sse/services/combo/quotaScoring.ts,open-sse/services/combo/quotaStrategies.ts"
|
||||
- name: g
|
||||
mutate: "open-sse/handlers/chatCore/comboContextCache.ts,open-sse/handlers/chatCore/idempotency.ts,open-sse/handlers/chatCore/passthroughHelpers.ts,open-sse/handlers/chatCore/responseHeaders.ts,open-sse/handlers/chatCore/sanitization.ts,open-sse/handlers/chatCore/upstreamTimeouts.ts"
|
||||
- name: h
|
||||
mutate: "open-sse/handlers/chatCore/headers.ts,open-sse/handlers/chatCore/logTruncation.ts,open-sse/handlers/chatCore/memoryExtraction.ts,open-sse/handlers/chatCore/nonStreamingSse.ts,open-sse/handlers/chatCore/passthroughToolNames.ts,open-sse/handlers/chatCore/executorHelpers.ts"
|
||||
- name: i
|
||||
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Run Stryker (disableBail)
|
||||
env:
|
||||
BATCH_MUTATE: ${{ matrix.batch.mutate }}
|
||||
run: npx stryker run --config-file stryker.disablebail.json --mutate "$BATCH_MUTATE"
|
||||
- name: Upload mutation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-nobail-${{ matrix.batch.name }}
|
||||
path: reports/mutation/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
126
.github/workflows/nightly-compat.yml
vendored
126
.github/workflows/nightly-compat.yml
vendored
@@ -1,126 +0,0 @@
|
||||
name: Nightly Node Compat
|
||||
|
||||
# Plano mestre testes+CI (Eixo D2, aprovado 2026-07-04): as matrizes de compatibilidade
|
||||
# Node 24/26 custavam ~28% de CADA run do CI pesado (2 execuções completas da suíte por
|
||||
# sync da release-PR) para pegar uma classe de quebra que raramente nasce num PR típico.
|
||||
# Elas rodam aqui 1×/dia contra o tip da release ativa (mesmo alvo do nightly-release-green)
|
||||
# e continuam obrigatórias no gate de release via workflow_dispatch do ci.yml se preciso.
|
||||
# fail-fast desligado: numa quebra queremos saber TODAS as versões afetadas de uma vez.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 6 * * *" # 06:47 UTC diário — slot distinto dos demais nightlies
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to validate (default: highest release/vX.Y.Z)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: nightly-compat
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
resolve-branch:
|
||||
name: Resolve active release branch
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
target: ${{ steps.branch.outputs.target }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Resolve active release branch
|
||||
id: branch
|
||||
env:
|
||||
INPUT_BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_BRANCH:-}" ]; then
|
||||
TARGET="$INPUT_BRANCH"
|
||||
else
|
||||
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
|
||||
| sed 's#origin/##' \
|
||||
| sort -t/ -k2 -V \
|
||||
| tail -1)
|
||||
fi
|
||||
case "$TARGET" in
|
||||
release/v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Refusing non-canonical branch name: $TARGET"; exit 1 ;;
|
||||
esac
|
||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
|
||||
compat-build-26:
|
||||
name: Node 26 Compatibility Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: resolve-branch
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "26"
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run build
|
||||
|
||||
compat-tests:
|
||||
name: Node ${{ matrix.node }} Compat Tests (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: resolve-branch
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [24, 26]
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
TEST_SHARD: ${{ matrix.shard }}/4
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:unit:ci:shard
|
||||
|
||||
report:
|
||||
name: Open / update tracking issue on failure
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && (needs.compat-tests.result == 'failure' || needs.compat-build-26.result == 'failure') }}
|
||||
needs: [resolve-branch, compat-build-26, compat-tests]
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Open or update issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ needs.resolve-branch.outputs.target }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="🌙 nightly-compat: Node 24/26 failures on $TARGET"
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "$TITLE in:title" --json number --jq '.[0].number')
|
||||
BODY="Nightly Node-compat run failed on \`$TARGET\`: $RUN_URL — triage which Node version/shard broke (fail-fast off, all versions reported)."
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body "$BODY"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY"
|
||||
fi
|
||||
6
.github/workflows/nightly-llm-security.yml
vendored
6
.github/workflows/nightly-llm-security.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
name: promptfoo — injection guard (block mode, no secret)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::PROMPTFOO_PROVIDER_KEY not set — skipping garak probes (advisory)."
|
||||
fi
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v5
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
with: { python-version: "3.12" }
|
||||
- run: pip install garak
|
||||
|
||||
147
.github/workflows/nightly-mutation.yml
vendored
147
.github/workflows/nightly-mutation.yml
vendored
@@ -9,102 +9,20 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stryker:
|
||||
name: Stryker mutation (batch ${{ matrix.batch.name }} — advisory)
|
||||
name: Stryker mutation testing (8 critical modules — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
# Mutation testing is expensive. History of the budget:
|
||||
# - Full 8-module set TIMED OUT at the 180min cap (run 27705123780 = exactly 180min).
|
||||
# The two god-files chatCore.ts/combo.ts dominated ~2/3 of the mutants and were
|
||||
# removed from stryker.conf.json `mutate`.
|
||||
# - The remaining 6 modules in 3 batches: auth.ts and accountFallback.ts are large; the
|
||||
# a (auth+publicCreds) and b (accountFallback+error) batches still ran near the 180min
|
||||
# cap (the perTest dry-run over ~130 covering test files is itself costly), so the two
|
||||
# big modules are now ISOLATED into their own batches (a=auth, b=accountFallback).
|
||||
# - Onda 3 / Fase 9 T5 re-add: combo.ts was split into 11 leaves; the 8 well-covered
|
||||
# combo/* leaves are back in `mutate` (covered by the 24 combo-*.test.ts), grouped into
|
||||
# 2 batches (d=heavy, e=light). After #4204 (D7b) merged, the reset-aware quota pair
|
||||
# quotaScoring/quotaStrategies was added as batch f. A covering-test audit then added the
|
||||
# 6 chatCore/* leaves with direct unit coverage as batch g. A follow-up then wrote dedicated
|
||||
# unit tests for 6 more leaves and added them as batch h. A final follow-up added dedicated
|
||||
# tests (no mock.module — fetch-override + crafted inputs + temp-DATA_DIR) for telemetryHelpers
|
||||
# + memorySkillsInjection + semanticCache (its cache-HIT block now has a setCachedResponse
|
||||
# fixture) as batch i — ALL 15/15 chatCore leaves are now mutated. See
|
||||
# _mutate_godfiles_excluded_comment in stryker.conf.json.
|
||||
# 9 PARALLEL batches, each overriding the mutate set via `--mutate` (Stryker 9 CLI:
|
||||
# `-m, --mutate <comma-list>`; the conf's `mutate[]` remains the local-run default/union).
|
||||
# - Cold-seeding budget (per-batch `timeout-minutes: ${{ matrix.batch.timeout || 180 }}`):
|
||||
# a COLD run must COMPLETE once to write stryker-incremental.json (Stryker writes it only on
|
||||
# a successful finish); a job cancelled at the cap writes nothing, so the next run is cold
|
||||
# again — an infinite never-seeds loop. actions/cache is also branch-scoped, so each branch
|
||||
# (incl. release) must seed its OWN cache via a run with enough headroom. Measured cold runs
|
||||
# Measured cold totals (run 27801802713, extrapolated from the % at the 180/350 cancel point):
|
||||
# auth ~375min (2301 mutants — EXCEEDS the 360min job max even isolated), accountFallback
|
||||
# ~358min (1441), the c security quartet ~348min (1163), d ~197min (1316), g=142, h=132, e=66,
|
||||
# f=45, i=33. The widely-covered modules blow the budget because the tap-runner re-runs every
|
||||
# covering test file per mutant (a perTest fixed cost over ~138 test files, times thousands of
|
||||
# mutants). A flat timeout bump cannot rescue auth (>360min max) — so the three over-budget
|
||||
# batches are SPLIT so each half fits: auth->a1/a2 and accountFallback->b1/b2 by mutation range
|
||||
# (`file:startLine-endLine`), the c quartet->c1/c2 by module pair. Splitting also seeds each
|
||||
# sub-batch's own incremental cache, after which nightlies re-test only changed mutants.
|
||||
# Full coverage every night in parallel; wall-clock = the slowest batch's cold run until seeded.
|
||||
# Runs at stryker concurrency=4 with per-process DATA_DIR isolation
|
||||
# (tests/_setup/isolateDataDir.ts) — see _concurrency_comment in stryker.conf.json.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
batch:
|
||||
# Per-batch `timeout` (minutes) tiers the cold-seeding budget by measured cost; batches
|
||||
# without the key default to 180. Cold-run profiling (run 27801802713) showed the
|
||||
# widely-covered modules need FAR more than the 180 cap because the tap-runner re-runs every
|
||||
# covering test file per mutant: auth ~375min (2301 mutants, EXCEEDS the 360min GitHub job
|
||||
# max even isolated), accountFallback ~358min, the c security quartet ~348min — none fit a
|
||||
# single job. So auth/accountFallback are split by MUTATION RANGE (`file:startLine-endLine`,
|
||||
# ~half the mutants each) into a1/a2, b1/b2; the c quartet is split by MODULE pair into
|
||||
# c1/c2. d (3 combo modules, ~197min) stays whole. Split-heavy batches get 300min headroom
|
||||
# for their cold seeding run; once each batch completes once and writes
|
||||
# stryker-incremental.json, later runs re-test only changed mutants and finish far faster.
|
||||
# g/h (142/132min cold) keep a 240 buffer; e/f/i (33-66min) keep the 180 default.
|
||||
- name: a1
|
||||
mutate: "src/sse/services/auth.ts:1-1109"
|
||||
timeout: 300
|
||||
- name: a2
|
||||
mutate: "src/sse/services/auth.ts:1110-2218"
|
||||
timeout: 300
|
||||
- name: b1
|
||||
mutate: "open-sse/services/accountFallback.ts:1-863"
|
||||
timeout: 300
|
||||
- name: b2
|
||||
mutate: "open-sse/services/accountFallback.ts:864-1726"
|
||||
timeout: 300
|
||||
- name: c1
|
||||
mutate: "src/server/authz/routeGuard.ts,src/shared/utils/circuitBreaker.ts"
|
||||
timeout: 300
|
||||
- name: c2
|
||||
mutate: "open-sse/utils/error.ts,open-sse/utils/publicCreds.ts"
|
||||
timeout: 300
|
||||
- name: d
|
||||
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
|
||||
timeout: 300
|
||||
- name: e
|
||||
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
|
||||
- name: f
|
||||
mutate: "open-sse/services/combo/quotaScoring.ts,open-sse/services/combo/quotaStrategies.ts"
|
||||
- name: g
|
||||
mutate: "open-sse/handlers/chatCore/comboContextCache.ts,open-sse/handlers/chatCore/idempotency.ts,open-sse/handlers/chatCore/passthroughHelpers.ts,open-sse/handlers/chatCore/responseHeaders.ts,open-sse/handlers/chatCore/sanitization.ts,open-sse/handlers/chatCore/upstreamTimeouts.ts"
|
||||
timeout: 240
|
||||
- name: h
|
||||
mutate: "open-sse/handlers/chatCore/headers.ts,open-sse/handlers/chatCore/logTruncation.ts,open-sse/handlers/chatCore/memoryExtraction.ts,open-sse/handlers/chatCore/nonStreamingSse.ts,open-sse/handlers/chatCore/passthroughToolNames.ts,open-sse/handlers/chatCore/executorHelpers.ts"
|
||||
timeout: 240
|
||||
- name: i
|
||||
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
|
||||
# Per-batch budget: split-heavy batches (a1/a2/b1/b2/c1/c2/d) override to 300min, g/h to 240min;
|
||||
# the rest default to 180min. `matrix.batch.timeout` is null for batches without the key -> `|| 180`.
|
||||
# NOTE: a1+a2 both mutate auth.ts (disjoint line ranges) and b1+b2 both mutate accountFallback.ts;
|
||||
# when merging the per-batch mutation.json for radiography/scores, same-file mutants from sibling
|
||||
# ranges must be UNIONED (scripts/check/check-mutation-ratchet.mjs::measureMutationScores and
|
||||
# scripts/quality/mutation-radiography.mjs both merge per file).
|
||||
timeout-minutes: ${{ matrix.batch.timeout || 180 }}
|
||||
# Mutation testing is expensive (~15k mutants across 8 modules). It runs only
|
||||
# on the nightly schedule / manual dispatch, never on PRs. The score is NOT
|
||||
# yet enforced as a ratchet (wired in a later INT phase) — for now the job
|
||||
# just produces the HTML/JSON report and uploads it as an artifact.
|
||||
#
|
||||
# Runs at stryker concurrency=4 (was 1) now that ./tests/_setup/isolateDataDir.ts
|
||||
# gives each test process its own DATA_DIR — at concurrency=1 the full phase did
|
||||
# not finish inside the timeout (the cancelled 2026-06-16/17 runs). 180 min leaves
|
||||
# headroom for the first cold run before the incremental cache is seeded.
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -113,51 +31,20 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore Stryker incremental cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: reports/mutation/stryker-incremental.json
|
||||
key: stryker-incremental-${{ matrix.batch.name }}-${{ github.run_id }}
|
||||
restore-keys: stryker-incremental-${{ matrix.batch.name }}-
|
||||
key: stryker-incremental-${{ github.run_id }}
|
||||
restore-keys: stryker-incremental-
|
||||
- name: Run Stryker (advisory)
|
||||
id: stryker
|
||||
continue-on-error: true
|
||||
env:
|
||||
BATCH_MUTATE: ${{ matrix.batch.mutate }}
|
||||
run: npx stryker run --mutate "$BATCH_MUTATE"
|
||||
run: npx stryker run
|
||||
- name: Upload mutation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-report-${{ matrix.batch.name }}
|
||||
name: mutation-report
|
||||
path: reports/mutation/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
# Aggregation gate (T3): each split batch emits a PARTIAL view of a mutated file
|
||||
# (auth.ts lives in a1+a2, accountFallback in b1+b2), so a PER-BATCH ratchet would
|
||||
# only ever see half a file vs the whole-file baseline. This job runs AFTER every
|
||||
# batch, downloads all reports, and ratchets the MERGED per-module scores
|
||||
# (check-mutation-ratchet UNIONS same-file mutants across reports) against the
|
||||
# dedicatedGate `mutationScore.*` floors in quality-baseline.json (seeded ~2pt below
|
||||
# the first full measurement). Blocking: a module dropping below its floor fails the
|
||||
# run. Missing reports (e.g. an artifact-upload flake) are skipped, never failed.
|
||||
mutation-ratchet:
|
||||
name: Mutation score ratchet (blocking)
|
||||
needs: stryker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Download all mutation reports
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: mutation-report-*
|
||||
path: reports/all
|
||||
- name: Ratchet merged per-module mutation scores
|
||||
run: node scripts/check/check-mutation-ratchet.mjs reports/all/*/mutation.json --ratchet
|
||||
|
||||
2
.github/workflows/nightly-property.yml
vendored
2
.github/workflows/nightly-property.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
property-random-seed:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
||||
303
.github/workflows/nightly-release-green.yml
vendored
303
.github/workflows/nightly-release-green.yml
vendored
@@ -1,303 +0,0 @@
|
||||
name: Release-Green (continuous)
|
||||
|
||||
# Solution D — continuous, NON-BLOCKING drift signal for the active release branch.
|
||||
#
|
||||
# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds
|
||||
# accrue silently on release/** and explode — in layers — at release time. This
|
||||
# workflow reproduces the release-equivalent validation on the release branch and,
|
||||
# when there are HARD failures, opens/updates a single tracking issue.
|
||||
#
|
||||
# WS5.1 (v3.8.49 quality plan) — two modes:
|
||||
# push to release/v* (code paths) → --quick (fast HARD gates, ~5-8min). Catches the
|
||||
# captain's direct pushes (sync-back — the one ungated write path) AND the merged
|
||||
# COMBINATION right after every PR merge, attributing the offending push range in
|
||||
# the issue. Base-red MTTD drops from ≤24h to ≤~15min after the offending push.
|
||||
# schedule (3×/day) → full --with-build --full-ci (the deep sweep incl. build+suites).
|
||||
#
|
||||
# It is NOT a required status check and never touches a contributor PR — it only
|
||||
# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is
|
||||
# expected mid-cycle and is reported but never raises the alarm on its own; only
|
||||
# real defects (typecheck / lint errors / unit / vitest / db-rules / public-creds /
|
||||
# package-artifact) flip the issue open.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["release/v*", "main"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "open-sse/**"
|
||||
- "bin/**"
|
||||
- "electron/**"
|
||||
- "scripts/**"
|
||||
- "tests/**"
|
||||
- "config/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "tsconfig*.json"
|
||||
schedule:
|
||||
- cron: "23 5 * * *" # full sweep — off-peak, distinct from other nightlies
|
||||
- cron: "23 12 * * *" # full sweep — midday (WS5.1: 3×/day instead of 1×)
|
||||
- cron: "23 18 * * *" # full sweep — evening
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Release branch to validate (default: highest release/vX.Y.Z)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
# push storms during merge campaigns collapse to the newest commit per branch;
|
||||
# scheduled full sweeps keep their own single lane.
|
||||
group: release-green-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
|
||||
|
||||
jobs:
|
||||
release-green:
|
||||
name: Validate active release branch
|
||||
# On a push, only run for release/* pushes — a push to main is handled by the
|
||||
# main-green job below. Schedule/dispatch always run (they validate the highest release).
|
||||
if: ${{ github.event_name != 'push' || startsWith(github.ref_name, 'release/') }}
|
||||
# Dynamic runner: with USE_VPS_RUNNER=true (release window / on-demand pre-flight)
|
||||
# this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY,
|
||||
# no local noauth CLIs => zero machine-specific false positives) and no contention.
|
||||
# Nightly cron normally finds the var false (VM off) and falls back to hosted.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve active release branch
|
||||
id: branch
|
||||
env:
|
||||
INPUT_BRANCH: ${{ github.event.inputs.branch }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PUSHED_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_BRANCH:-}" ]; then
|
||||
TARGET="$INPUT_BRANCH"
|
||||
elif [ "$EVENT_NAME" = "push" ]; then
|
||||
# validate exactly what was pushed, not the highest branch
|
||||
TARGET="$PUSHED_REF"
|
||||
else
|
||||
# highest release/vX.Y.Z by semver among remote branches
|
||||
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
|
||||
| sed 's#origin/##' \
|
||||
| sort -t/ -k2 -V \
|
||||
| tail -1)
|
||||
fi
|
||||
if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi
|
||||
# Strict format guard — reject anything that isn't release/vX.Y.Z (blocks
|
||||
# ref/command injection via the workflow_dispatch input).
|
||||
if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Refusing non-canonical branch name: $TARGET"; exit 1
|
||||
fi
|
||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
echo "Active release branch: $TARGET"
|
||||
|
||||
- name: Checkout the release branch
|
||||
env:
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git checkout "$TARGET"
|
||||
git log -1 --oneline
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
|
||||
- name: Release-green validation (full)
|
||||
id: validate
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set +e
|
||||
# --hermetic: scrub live-test trigger vars (self-hosted runner may carry
|
||||
# operator env; hosted ignores the unknown flag before #6300 lands).
|
||||
# push → --quick: fast HARD gates only (~5-8min), per-merge signal.
|
||||
# schedule/dispatch → --with-build --full-ci: ALSO run every static gate from
|
||||
# ci.yml's gate jobs (lint, quality-gate, quality-extended, docs-sync-strict,
|
||||
# pr-test-policy) + build + full suites. PRs into release/** only get the
|
||||
# fast-gates, so these accrue silently and explode in layers on the release PR
|
||||
# (v3.8.46: 11 static base-reds leaked).
|
||||
if [ "$EVENT_NAME" = "push" ]; then
|
||||
MODE="--quick"
|
||||
else
|
||||
MODE="--with-build --full-ci"
|
||||
fi
|
||||
echo "[release-green] mode: $MODE (event: $EVENT_NAME)"
|
||||
# shellcheck disable=SC2086 — MODE is an intentional flag list
|
||||
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
|
||||
1> release-green.json 2> release-green.log
|
||||
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
||||
echo "------- report -------"
|
||||
cat release-green.log
|
||||
|
||||
- name: Open / update tracking issue on HARD failure
|
||||
if: steps.validate.outputs.exit != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
AFTER_SHA: ${{ github.event.after }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="🔴 Release branch not green: ${TARGET}"
|
||||
{
|
||||
echo "The **release-green** validation found HARD failures on \`${TARGET}\`."
|
||||
echo "These are real defects that would block the release PR — fix them in the"
|
||||
echo "originating PR branch (via co-authorship), not by demanding it from contributors."
|
||||
echo ""
|
||||
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
|
||||
# WS5.1 attribution: on push events the offending change IS this push's range
|
||||
# (one merge per push in the normal queue), so name it — no bisect needed.
|
||||
if [ "$EVENT_NAME" = "push" ] && [ -n "${BEFORE_SHA:-}" ] && \
|
||||
git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
|
||||
echo ""
|
||||
echo "**Offending push range** (\`${BEFORE_SHA:0:9}..${AFTER_SHA:0:9}\`):"
|
||||
echo '```'
|
||||
git log --no-decorate --oneline "${BEFORE_SHA}..${AFTER_SHA}" | head -20
|
||||
echo '```'
|
||||
fi
|
||||
echo ""
|
||||
echo '```'
|
||||
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) listed above is expected mid-cycle and is rebaselined at release — it is NOT a contributor concern and did not, on its own, open this issue._"
|
||||
} > issue-body.md
|
||||
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
|
||||
echo "Updated existing issue #$EXISTING"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-green-report
|
||||
path: |
|
||||
release-green.json
|
||||
release-green.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Companion arm for `main`. Under the parallel-cycle model, main only receives merged
|
||||
# work at the release squash — so a gate/infra fix that lands only on release leaves
|
||||
# main red the whole cycle, and repo-wide gates (CodeQL alert count, ratchet baselines)
|
||||
# turn EVERY PR into main red on a check unrelated to its diff. This detects that and
|
||||
# opens a "🔴 main not green" tracking issue. The PREVENTION is the companion-PR reflex
|
||||
# (Hard Rule #21 area / _shared/merge-gates.md §8); this is the automated backstop.
|
||||
main-green:
|
||||
name: Validate main branch
|
||||
# On a push, only run for a push to main — a push to release/* is handled by
|
||||
# release-green above. Schedule/dispatch always run (they also sweep main).
|
||||
if: ${{ github.event_name != 'push' || github.ref_name == 'main' }}
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
|
||||
- name: Main-green validation
|
||||
id: validate
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set +e
|
||||
# push (a merge into main) → --quick fast HARD gates; schedule/dispatch → full sweep.
|
||||
if [ "$EVENT_NAME" = "push" ]; then
|
||||
MODE="--quick"
|
||||
else
|
||||
MODE="--with-build --full-ci"
|
||||
fi
|
||||
echo "[main-green] mode: $MODE (event: $EVENT_NAME)"
|
||||
# shellcheck disable=SC2086 — MODE is an intentional flag list
|
||||
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
|
||||
1> main-green.json 2> main-green.log
|
||||
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
||||
echo "------- report -------"
|
||||
cat main-green.log
|
||||
|
||||
- name: Open / update tracking issue on HARD failure
|
||||
if: steps.validate.outputs.exit != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="🔴 main branch not green"
|
||||
{
|
||||
echo "The **main-green** validation found HARD failures on \`main\`."
|
||||
echo ""
|
||||
echo "Because \`main\` only receives merged work at the release squash, a gate/infra"
|
||||
echo "fix that landed only on the release branch leaves \`main\` broken for the whole"
|
||||
echo "cycle — and repo-wide gates (CodeQL alert count, ratchet baselines) then turn"
|
||||
echo "**every open PR into main** red on a check unrelated to its diff. The fix is a"
|
||||
echo "companion PR \`--base main\` carrying the release-side fix (see"
|
||||
echo "\`_shared/merge-gates.md\` §8), NOT chasing each contributor PR."
|
||||
echo ""
|
||||
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
|
||||
echo ""
|
||||
echo '```'
|
||||
sed -n '/──────── verdict ────────/,$p' main-green.log || tail -40 main-green.log
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) is expected mid-cycle and did NOT, on its own, open this issue._"
|
||||
} > issue-body.md
|
||||
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
|
||||
echo "Updated existing issue #$EXISTING"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: main-green-report
|
||||
path: |
|
||||
main-green.json
|
||||
main-green.log
|
||||
if-no-files-found: ignore
|
||||
40
.github/workflows/nightly-resilience.yml
vendored
40
.github/workflows/nightly-resilience.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Heap-growth gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
name: Resilience chaos (fault injection)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
name: k6 load/soak
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -74,37 +74,3 @@ jobs:
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
|
||||
a11y:
|
||||
name: A11y axe (nightly, freeze-and-alert)
|
||||
runs-on: ubuntu-latest
|
||||
# The Playwright webServer (`start` mode) builds Next via build-next-isolated.mjs and
|
||||
# boots the standalone server itself (waits on /api/monitoring/health, 15min webServer
|
||||
# timeout). Unlike the per-PR test-e2e job, this nightly job has no pre-built artifact,
|
||||
# so it self-builds — hence the generous job timeout. REQUIRE_AXE=1 makes the suite run
|
||||
# the real axe analysis (the 4 page tests are gated to nightly so per-PR e2e stays fast)
|
||||
# and makes the meta-test fail loudly if @axe-core/playwright ever goes missing.
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
REQUIRE_AXE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: playwright-chromium-${{ runner.os }}-
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Run axe a11y suite (self-building webServer)
|
||||
run: npx playwright test tests/e2e/a11y.spec.ts
|
||||
|
||||
6
.github/workflows/nightly-schemathesis.yml
vendored
6
.github/workflows/nightly-schemathesis.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.12" }
|
||||
- name: Install schemathesis
|
||||
run: pip install schemathesis
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# PROVE the contract is fuzzable and surface regressions, not to gate the build.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
schemathesis run docs/openapi.yaml \
|
||||
schemathesis run docs/reference/openapi.yaml \
|
||||
--url http://localhost:20128 \
|
||||
--max-examples 20 \
|
||||
--workers 4 \
|
||||
|
||||
111
.github/workflows/npm-publish.yml
vendored
111
.github/workflows/npm-publish.yml
vendored
@@ -22,14 +22,6 @@ on:
|
||||
- latest
|
||||
- next
|
||||
- historic
|
||||
publish_mode:
|
||||
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
|
||||
required: false
|
||||
default: "staged"
|
||||
type: choice
|
||||
options:
|
||||
- staged
|
||||
- direct
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
@@ -63,7 +55,7 @@ jobs:
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Need full tag history to compare against highest semver when
|
||||
@@ -174,34 +166,8 @@ jobs:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
|
||||
|
||||
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
|
||||
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
|
||||
# real tarball and fails the publish before anything reaches the registry.
|
||||
- name: Boot-smoke the tarball before ANY publish
|
||||
- name: Publish to npm
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: npm run check:pack-boot
|
||||
|
||||
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
|
||||
# parks the exact bytes on the registry WITHOUT making them installable; the
|
||||
# owner then verifies and approves with 2FA (`npm stage approve`), moving the
|
||||
# human gate to AFTER the proof instead of before it. Requires npm >= 11.15
|
||||
# (staged publishing GA 2026-05-22). publish_mode=direct is the emergency
|
||||
# fallback (legacy immediate publish) via workflow_dispatch.
|
||||
- name: Ensure npm supports staged publishing
|
||||
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CUR=$(npm --version)
|
||||
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
|
||||
# Pinned exact version (supply-chain: never float @latest in the publish
|
||||
# job); bump deliberately when a newer npm is required.
|
||||
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
|
||||
npm install -g --ignore-scripts npm@11.15.0
|
||||
fi
|
||||
npm --version
|
||||
|
||||
- name: Publish to npm (staged — owner approves with 2FA)
|
||||
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
@@ -209,32 +175,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Always pass --tag explicitly. Defense in depth: even if VERSION is
|
||||
# accidentally an older release, the historic tag will NOT claim `@latest`.
|
||||
npm stage publish --provenance --access public --tag "$TAG"
|
||||
{
|
||||
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
|
||||
echo ""
|
||||
echo "The exact bytes are parked on the registry. To release them:"
|
||||
echo '```'
|
||||
echo "npm stage list omniroute # find the stage id"
|
||||
echo "npm stage approve <id> # owner 2FA — THE publish"
|
||||
echo '```'
|
||||
echo "To verify the staged bytes first: npm stage download <id> → run"
|
||||
echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)."
|
||||
echo "To discard: npm stage reject <id>."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
|
||||
|
||||
- name: Publish to npm (DIRECT — emergency fallback)
|
||||
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# accidentally an older release, `npm publish --tag historic` will
|
||||
# NOT promote it to `@latest`.
|
||||
npm publish --provenance --access public --tag "$TAG"
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
|
||||
|
||||
- name: Publish to GitHub Packages
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
@@ -258,11 +202,9 @@ jobs:
|
||||
id-token: write # npm provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# Full history needed for auto-bump: git diff against previous release tag
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -270,47 +212,6 @@ jobs:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Auto-bump plugin version if plugin changed since last release
|
||||
id: bump
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
env:
|
||||
CURRENT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
PKG_NAME=$(node -p "require('./package.json').name")
|
||||
|
||||
# 1) Skip if current version is not yet published (no bump needed)
|
||||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||||
if [ "$PUBLISHED" != "$PKG_VERSION" ]; then
|
||||
echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2) Find the previous release tag (exclude the current one)
|
||||
PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \
|
||||
| grep -v "^${CURRENT_TAG}$" | head -1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag to compare — skipping bump."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3) Check if plugin dir actually changed since that tag
|
||||
if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin/"; then
|
||||
echo "⏭️ No plugin changes since $PREV_TAG — nothing to publish."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4) Auto-bump patch version
|
||||
npm version patch --no-git-tag-version --allow-same-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "bumped=true" >> "$GITHUB_OUTPUT"
|
||||
echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}"
|
||||
|
||||
- name: Install plugin dependencies
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
4
.github/workflows/opencode-plugin-ci.yml
vendored
4
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
matrix:
|
||||
node: ["22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
||||
4
.github/workflows/opencode-provider-ci.yml
vendored
4
.github/workflows/opencode-provider-ci.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
matrix:
|
||||
node: ["20", "22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
|
||||
302
.github/workflows/quality.yml
vendored
302
.github/workflows/quality.yml
vendored
@@ -14,80 +14,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# CI must never mutate the runner's OS trust store (2026-07-05: a cert-flow
|
||||
# test installed a fake PEM on a persistent self-hosted runner and broke all
|
||||
# system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts.
|
||||
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
|
||||
CI_NODE_VERSION: "24"
|
||||
|
||||
jobs:
|
||||
# Same classifier as ci.yml (scripts/quality/classify-pr-changes.mjs) so PR→release
|
||||
# path filters share existence reasons: code / docs / i18n / workflow.
|
||||
changes:
|
||||
name: Change Classification
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code: ${{ steps.classify.outputs.code }}
|
||||
docs: ${{ steps.classify.outputs.docs }}
|
||||
i18n: ${{ steps.classify.outputs.i18n }}
|
||||
workflow: ${{ steps.classify.outputs.workflow }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||
{
|
||||
echo "code=true"
|
||||
echo "docs=true"
|
||||
echo "i18n=true"
|
||||
echo "workflow=true"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
|
||||
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Docs/OpenAPI contract gates only — existence reason is doc accuracy + route refs.
|
||||
# Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs.
|
||||
docs-gates:
|
||||
name: Docs Gates (fast-path)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- 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)
|
||||
run: npm run check:docs-all
|
||||
|
||||
fast-gates:
|
||||
name: Fast Quality Gates
|
||||
needs: changes
|
||||
# Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs).
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
|
||||
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
|
||||
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
|
||||
# or a fork PR falls back to ubuntu-latest, so this is inert until the flag flips.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
# tsx gates (known-symbols, route-guard-membership) import modules that open
|
||||
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||
env:
|
||||
@@ -95,7 +27,7 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -104,18 +36,10 @@ jobs:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:fetch-targets
|
||||
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
|
||||
- run: npm run check:openapi-routes
|
||||
- run: npm run check:docs-symbols
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
- run: npm run check:error-helper
|
||||
@@ -125,52 +49,21 @@ jobs:
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:test-runner-api
|
||||
# Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json
|
||||
# tap.testFiles makes its module's mutants survive on a cold nightly-mutation run,
|
||||
# false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs.
|
||||
- run: npm run check:mutation-test-coverage
|
||||
- run: npm run check:any-budget:t11
|
||||
# Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
|
||||
# scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
|
||||
- run: npm run check:build-scope
|
||||
# Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file
|
||||
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
|
||||
# the release PR's heavy Package Artifact job.
|
||||
- run: npm run check:pack-policy
|
||||
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
|
||||
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
|
||||
- run: npm run check:complexity-ratchets
|
||||
- name: Typecheck (core)
|
||||
run: npm run typecheck:core
|
||||
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
|
||||
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
|
||||
- name: Typecheck (dashboard)
|
||||
run: npm run check:dashboard-typecheck
|
||||
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
|
||||
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
|
||||
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
|
||||
# (the hybrid is the officially documented pattern). Isolated npx on purpose:
|
||||
# installing an alias package could collide node_modules/.bin/tsc with 6.x.
|
||||
# Promote to the blocking gate after ~1 week of parity with the step above.
|
||||
- name: Typecheck (core) — TS7 native shadow (advisory)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
RC=0
|
||||
START=$(date +%s)
|
||||
npx -y -p typescript@7 tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
|
||||
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
|
||||
exit $RC
|
||||
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
|
||||
# unit tests impacted by this PR's changed files. On hub/unmapped changes the
|
||||
# selector returns __RUN_ALL__ — full-suite authority is the parallel
|
||||
# `fast-unit` 4-shard job (test:unit:ci:shard; was 2-shard, #6781), NOT an
|
||||
# unsharded re-run here. Stacking unsharded test:unit:ci on top of fast-unit
|
||||
# doubled wall time (~16 min extra on ubuntu-latest) without extra coverage.
|
||||
# unit tests impacted by this PR's changed files. Fail-safe runs the FULL
|
||||
# unit suite on hub/unmapped changes — TIA accelerates, never replaces, the net.
|
||||
#
|
||||
# BLOCKING for the *impacted subset* (flipped 2026-06-17). Fail-safe full
|
||||
# coverage remains required via `Unit Tests fast-path` (fast-unit).
|
||||
- name: Impacted unit tests (TIA subset; blocking)
|
||||
# BLOCKING (flipped 2026-06-17). The pre-existing release unit test-debt that kept
|
||||
# this advisory was cleared: #4030 (16 Zod/registry reds, lossless restore) and
|
||||
# #4063 (the last red — the LiveWS boot test — root-caused as a real event-loop
|
||||
# stall in the WS sidecar, fixed + relocated to the integration suite). A full
|
||||
# ci.yml run on release/v3.8.28 then showed all 8 unit shards green, so PR->release
|
||||
# now blocks on unit-test regressions in the impacted set (typecheck:core already
|
||||
# blocked above). Fail-safe still runs the FULL unit suite on hub/unmapped changes.
|
||||
- name: Impacted unit tests (TIA, fail-safe full; blocking)
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
@@ -184,171 +77,8 @@ jobs:
|
||||
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
|
||||
# run race-free regardless of concurrency.
|
||||
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
||||
echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)."
|
||||
echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)."
|
||||
exit 0
|
||||
echo "Fail-safe: running FULL unit suite (CI concurrency)"; npm run test:unit:ci; exit $?
|
||||
fi
|
||||
echo "Running impacted tests:"; echo "$SEL"
|
||||
mapfile -t FILES <<< "$SEL"
|
||||
# Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs
|
||||
# under `--import tsx` (CJS transform — required for ESM-only deep imports like
|
||||
# @lobehub/icons/es/* reached via lobeProviderIcons.ts); everything else under
|
||||
# `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard
|
||||
# module-shape test the impact map selects ("Unexpected token 'export'").
|
||||
DASH=(); REST=()
|
||||
for f in "${FILES[@]}"; do
|
||||
case "$f" in
|
||||
tests/unit/dashboard/*) DASH+=("$f") ;;
|
||||
*) REST+=("$f") ;;
|
||||
esac
|
||||
done
|
||||
RC=0
|
||||
if [ ${#REST[@]} -gt 0 ]; then
|
||||
node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${REST[@]}" || RC=$?
|
||||
fi
|
||||
if [ ${#DASH[@]} -gt 0 ]; then
|
||||
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$?
|
||||
fi
|
||||
exit $RC
|
||||
|
||||
fast-vitest:
|
||||
name: Vitest (fast-path)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- 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.
|
||||
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
|
||||
- name: Upload test results to Trunk (advisory)
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
continue-on-error: true
|
||||
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
|
||||
with:
|
||||
junit-paths: trunk-junit/**/*.xml
|
||||
org-slug: omniroute
|
||||
token: ${{ secrets.TRUNK_TOKEN }}
|
||||
|
||||
fast-unit:
|
||||
name: Unit Tests fast-path (${{ matrix.shard }}/4)
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
|
||||
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
|
||||
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
|
||||
# runner box). Node's native --test-shard=N/total takes any denominator — only
|
||||
# this matrix and the TEST_SHARD env below encode the shard count.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- 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.
|
||||
- run: npm run test:unit:ci:shard
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/4
|
||||
|
||||
# ── Pacote 4 (plano mestre testes+CI, aprovado 2026-07-04) ─────────────────────────
|
||||
# No-new-warnings por PR via ESLint bulk suppressions nativo (>=9.24). O baseline
|
||||
# config/quality/eslint-suppressions.json congela as violações EXISTENTES por
|
||||
# arquivo+regra; qualquer warning NOVO aparece e o --max-warnings 0 falha o job — o
|
||||
# drift de +41/+88 warnings por ciclo passa a morrer no PR que o introduz, em vez de
|
||||
# ser rebaselinado às cegas na release. Aperto do baseline (na reconciliação da
|
||||
# release): npx eslint . --prune-suppressions --suppressions-location config/quality/eslint-suppressions.json
|
||||
#
|
||||
# Princípio Zero: bloqueante SÓ para branches internas (as campanhas/sessões são a
|
||||
# origem do drift). PR de FORK roda em modo report (continue-on-error → o job fica
|
||||
# verde com anotação; a campanha /green-prs aplica o fix via co-autoria — o
|
||||
# contribuidor NUNCA é bloqueado nem cobrado).
|
||||
lint-guard:
|
||||
name: No new ESLint warnings
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
- name: ESLint (baseline congelado — warning novo = vermelho)
|
||||
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
|
||||
run: npm run lint:json -- --max-warnings 0
|
||||
|
||||
# Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só
|
||||
# explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come
|
||||
# bullets vizinhos/seções inteiras (incidente #6193, 2026-07-05: 212 linhas /
|
||||
# 130 bullets); o checkout de PR é refs/pull/N/merge, então comparar contra a
|
||||
# base detecta o eat ANTES do merge. (2) SKILL.md gerado stale vs o catálogo de
|
||||
# agent-skills (#6186 mergeou um id de catálogo sem rodar o gerador → 8 reds de
|
||||
# integration invisíveis até a release).
|
||||
#
|
||||
# Princípio Zero: bloqueante SÓ para branches internas; PR de FORK roda em modo
|
||||
# report (continue-on-error) — a campanha corrige via co-autoria, o contribuidor
|
||||
# nunca é bloqueado.
|
||||
merge-integrity:
|
||||
name: Merge integrity (changelog + generated skills)
|
||||
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }}
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- 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)
|
||||
run: npm run check:agent-skills-sync
|
||||
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}"
|
||||
|
||||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/semgrep.yml
vendored
2
.github/workflows/semgrep.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
container:
|
||||
image: semgrep/semgrep
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run semgrep (advisory)
|
||||
|
||||
4
.github/workflows/wiki-sync.yml
vendored
4
.github/workflows/wiki-sync.yml
vendored
@@ -9,7 +9,7 @@ name: Wiki Sync
|
||||
# It does NOT overwrite existing wiki pages by default: several docs sources still carry
|
||||
# stale counts (e.g. ARCHITECTURE.md says "177 providers" while the wiki cover is 226),
|
||||
# so blind overwrite would regress the wiki. Full content parity (--update-existing) is
|
||||
# gated on regenerating those sources first.
|
||||
# gated on regenerating those sources — see docs/ops/DOCUMENTATION_AUDIT_REPORT.md.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -72,7 +72,6 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.homolog.example
|
||||
# Provider API keys (never commit)
|
||||
*.api-key
|
||||
.nvidia-api-key
|
||||
@@ -162,8 +161,6 @@ typescript
|
||||
|
||||
# Superpowers plans/specs (internal tooling, not project code)
|
||||
docs/superpowers/
|
||||
# Superpowers visual-companion brainstorm mockups (ephemeral)
|
||||
.superpowers/
|
||||
|
||||
# TIA test-impact map — generated at runtime in CI (build-test-impact-map.mjs), never committed (~21MB)
|
||||
config/quality/test-impact-map.json
|
||||
@@ -230,22 +227,3 @@ docs/prompts/AGENT-OWNERSHIP-PROTOCOL.md
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mim.md
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mid.md
|
||||
omniroute.md
|
||||
|
||||
# mise configuration
|
||||
mise.toml
|
||||
_artifacts/
|
||||
.claude-flow/
|
||||
|
||||
# ESLint file cache (npm run lint --cache / complexity ratchets)
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
|
||||
|
||||
# CI/local quality artifacts (eslint-results.json, etc.)
|
||||
.artifacts/
|
||||
|
||||
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
|
||||
.env.homolog
|
||||
tests/homolog/.auth/
|
||||
tests/homolog/ui/.auth/
|
||||
homolog-report/
|
||||
|
||||
@@ -74,16 +74,3 @@
|
||||
# '''tests/unit/''',
|
||||
# ]
|
||||
#
|
||||
|
||||
[[rules]]
|
||||
# Falsos-positivos comprovados do generic-api-key — zerados em 2026-07-13 (WS6/D3,
|
||||
# plano v3.8.49). Revisar em v3.9.0. Nenhum é credencial: dois são NOMES DE CAMPO
|
||||
# de métricas de latência; o terceiro é o valor PÚBLICO de um beta header da API
|
||||
# da Anthropic (documentado publicamente, não é segredo).
|
||||
id = "generic-api-key"
|
||||
[rules.allowlist]
|
||||
description = "Field names + public Anthropic beta-header value (não são segredos)"
|
||||
regexes = [
|
||||
'''latencyP\d{2}Ms''',
|
||||
'''interleaved-thinking-2025-05-14''',
|
||||
]
|
||||
|
||||
@@ -10,4 +10,3 @@ fi
|
||||
npx lint-staged
|
||||
node scripts/check/check-docs-sync.mjs
|
||||
npm run check:any-budget:t11
|
||||
node scripts/check/check-tracked-artifacts.mjs
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
# .husky/pre-push — intentionally light.
|
||||
# any-budget + tracked-artifacts already run on pre-commit; re-running them on
|
||||
# every push only doubles local wall time for the same existence reason (CI still
|
||||
# enforces both). Keep this hook as a PATH/npm sanity check + reminder.
|
||||
# Intentionally excludes test:unit / typecheck (slow; covered by CI).
|
||||
# .husky/pre-push — fast deterministic gates (<10s total)
|
||||
# Intentionally excludes test:unit (slow; covered by CI pre-push remote run).
|
||||
# Activated: 2026-06-13 (6A.12 — replaced commented-out test:unit stub)
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "⚠️ npm not found in PATH — skipping pre-push hooks"
|
||||
@@ -11,5 +9,4 @@ if ! command -v npm >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No-op success: real local gates live in pre-commit; CI owns the rest.
|
||||
exit 0
|
||||
npm run check:any-budget:t11 && npm run check:tracked-artifacts
|
||||
|
||||
17
.mcp.json.example
Normal file
17
.mcp.json.example
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$comment_purpose": "OPT-IN agent-lsp / LSP-in-the-loop (Quality Gates Fase 7 Task 15). Copy this file to `.mcp.json` to enable. It exposes a TypeScript language server to coding agents (Claude Code, etc.) so they get diagnostics / hover / go-to-definition / blast-radius BEFORE writing code — turning 'invented symbol' review-catches into impossible-at-edit-time. Pairs with `npm run typecheck:core` as a compile-before-claim check.",
|
||||
"$comment_safety": "Shipped as `.example` (NOT `.mcp.json`) on purpose so it never auto-loads an unvetted server into everyone's session. Pick an MCP<->LSP bridge you trust and have verified locally, then drop in its package + args below. A broken MCP entry only logs a connection error; it does not break agent sessions. The underlying language server is `typescript-language-server` (npm, mature) — install via `npm i -g typescript-language-server typescript` or rely on npx.",
|
||||
"mcpServers": {
|
||||
"typescript-lsp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"<your-mcp-lsp-bridge>",
|
||||
"--lsp",
|
||||
"typescript-language-server",
|
||||
"--stdio"
|
||||
],
|
||||
"$note": "Replace <your-mcp-lsp-bridge> with the concrete MCP<->LSP adapter you chose. It must speak MCP on stdio and proxy to `typescript-language-server --stdio`. Scope it to this repo's tsconfig (open-sse/tsconfig.json / tsconfig.json) for accurate diagnostics."
|
||||
}
|
||||
}
|
||||
}
|
||||
55
.mergify.yml
55
.mergify.yml
@@ -1,55 +0,0 @@
|
||||
# Mergify merge queue — WS3.4/D5 of the v3.8.49 quality/velocity master plan.
|
||||
#
|
||||
# WHY: ~85-100 active PR authors/month and 300+ PRs/week peaks, all merged by ONE
|
||||
# identity. The manual merge-train validated batches by hand; this queue automates
|
||||
# it with batching + automatic batch bisection (a red batch of N costs ~log2(N)
|
||||
# revalidations instead of N). Mergify Open Source plan: free, unlimited, public repo.
|
||||
#
|
||||
# GOVERNANCE (non-negotiable, mirrors CLAUDE.md Hard Rules #21/#22 + the owner's
|
||||
# pre-merge ⭐ gate):
|
||||
# • A PR enters the queue ONLY via the `queue` label — applied by the owner (or a
|
||||
# session acting for the owner) AFTER the pre-merge ⭐ report/decision. The label
|
||||
# IS the merge approval; Mergify only executes it.
|
||||
# • During a release-freeze (open issue labeled `release-freeze`), do NOT label PRs
|
||||
# targeting the frozen branch — the freeze is a human-honored coordination signal
|
||||
# the queue cannot see. Retarget to the active release/vX+1 first (Hard Rule #21).
|
||||
# • Never label a PR another session is actively working (Hard Rule #22b).
|
||||
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
|
||||
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
|
||||
|
||||
queue_rules:
|
||||
- name: release
|
||||
# Any current or future release branch — the reason GitHub's native queue was
|
||||
# rejected (no wildcard support on personal-account repos).
|
||||
queue_conditions:
|
||||
- base~=^release/v\d+\.\d+\.\d+$
|
||||
- label=queue
|
||||
- -draft
|
||||
- -conflict
|
||||
# "Everything that ran is green, nothing still running, AND the always-on
|
||||
# anchor check succeeded" — robust to the path-filtered fast-gates (docs-only
|
||||
# PRs skip code jobs; matrix shard names vary) while never fail-open: a PR with
|
||||
# zero checks cannot vacuously merge, because `Merge integrity` runs on EVERY
|
||||
# non-draft PR (quality.yml) and must be an affirmative success. Review approval
|
||||
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
|
||||
# approval in this repo's single-maintainer model (see governance header).
|
||||
merge_conditions:
|
||||
- "#check-failure=0"
|
||||
- "#check-pending=0"
|
||||
- "#check-success>=1"
|
||||
- check-success=Merge integrity (changelog + generated skills)
|
||||
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
|
||||
# don't hold a lone PR hostage waiting for siblings.
|
||||
batch_size: 10
|
||||
batch_max_wait_time: 5 min
|
||||
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
|
||||
merge_method: squash
|
||||
|
||||
pull_request_rules:
|
||||
- name: clean up the queue label after merge
|
||||
conditions:
|
||||
- merged
|
||||
actions:
|
||||
label:
|
||||
remove:
|
||||
- queue
|
||||
10
.npmrc
10
.npmrc
@@ -2,13 +2,3 @@
|
||||
# Keeping peer auto-install disabled prevents npm from pulling @lobehub/ui/mermaid
|
||||
# back into the tree and reopening npm audit findings for unused packages.
|
||||
legacy-peer-deps=true
|
||||
|
||||
# Network resilience: enlarge npm's fetch retry budget so a transient registry
|
||||
# socket reset (ECONNRESET) mid-download retries instead of failing the job.
|
||||
# npm defaults to only 2 retries with short timeouts; `npm ci` in
|
||||
# electron-release.yml hit ECONNRESET during v3.8.41 publish. Applies to every
|
||||
# CI workflow (electron / docker / unit) and local installs.
|
||||
fetch-retries=5
|
||||
fetch-retry-factor=4
|
||||
fetch-retry-mintimeout=20000
|
||||
fetch-retry-maxtimeout=120000
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
|
||||
docs/reference/ENVIRONMENT.md
|
||||
|
||||
# Dense auto-generated free-tier budget rows (one object per line) — prettier multi-line expand blows past file-size cap 800.
|
||||
open-sse/config/freeModelCatalog.data.ts
|
||||
22
.trivyignore
22
.trivyignore
@@ -1,22 +0,0 @@
|
||||
# .trivyignore — accepted-risk suppressions for the container image scan
|
||||
#
|
||||
# Policy (see docs/security/SUPPLY_CHAIN.md):
|
||||
# - The Trivy steps in .github/workflows/docker-publish.yml run with
|
||||
# `ignore-unfixed: true`, so vulnerabilities WITHOUT a published fix are
|
||||
# already excluded from both the blocking CRITICAL gate and the advisory
|
||||
# Security-tab upload. You do NOT need an entry here for an unfixable
|
||||
# base-image OS CVE — it will not be reported.
|
||||
# - This file is the single auditable home for the rare case where a *fixable*
|
||||
# CVE must be temporarily accepted (e.g. the upstream fix is not yet in the
|
||||
# pinned base tag, or the affected package/binary is provably unreachable
|
||||
# from the proxy request surface and rebuilding now is not justified).
|
||||
#
|
||||
# Format — one CVE id per line, each with a justification comment and, where
|
||||
# possible, an expiry, e.g.:
|
||||
# # CVE-XXXX-YYYY — <why accepted>; revisit on next base-image bump (YYYY-MM-DD)
|
||||
# CVE-XXXX-YYYY
|
||||
#
|
||||
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
|
||||
# patched base / bump the dep) over suppressing. Stale entries are debt.
|
||||
#
|
||||
# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.)
|
||||
19
.vscode/settings.json
vendored
19
.vscode/settings.json
vendored
@@ -20,15 +20,6 @@
|
||||
},
|
||||
"git.ignoreLimitWarning": true,
|
||||
|
||||
// ─── Git: não adicionar os ~44 repos aninhados (9 worktrees + _references/* +
|
||||
// _mono_repo/* + _ideia/_tasks/.agents) ao Source Control. Era a causa do
|
||||
// "validando muito": um git status + watcher por repo. Só o repo raiz fica. ───
|
||||
"git.autoRepositoryDetection": false,
|
||||
"git.repositoryScanMaxDepth": 0,
|
||||
"git.detectSubmodules": false,
|
||||
"git.autofetch": false,
|
||||
"git.autorefresh": true,
|
||||
|
||||
// ─── Performance: não seguir o symlink de node_modules criado pelas worktrees ───
|
||||
// As worktrees em .worktrees/ apontam node_modules para o checkout principal;
|
||||
// sem isto a busca atravessa o symlink mesmo com node_modules excluído.
|
||||
@@ -48,19 +39,11 @@
|
||||
"**/.build",
|
||||
"**/dist",
|
||||
"**/coverage",
|
||||
"**/.worktrees",
|
||||
"**/.claude/worktrees",
|
||||
"**/electron",
|
||||
"**/_references",
|
||||
"**/_mono_repo",
|
||||
"**/_tasks"
|
||||
"**/.worktrees"
|
||||
]
|
||||
},
|
||||
|
||||
// Para esconder os diretórios gerados da árvore do Explorer, descomente:
|
||||
// (MANTIDO comentado — o dono precisa ver _references/_mono_repo/_tasks na árvore.
|
||||
// A performance é resolvida por watcherExclude + search.exclude + tsserver, sem
|
||||
// precisar escondê-los do Explorer.)
|
||||
// "files.exclude": {
|
||||
// "**/.worktrees": true,
|
||||
// "**/coverage": true,
|
||||
|
||||
@@ -196,7 +196,7 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
|
||||
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
|
||||
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached` → `cacheRead`, `cache_creation` → `cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
|
||||
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
|
||||
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models` → `GHM`, `Gemini` → `GEMINI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). |
|
||||
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models` → `GHM`, `Gemini-cli` → `GEMINI-CLI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). |
|
||||
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
|
||||
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
|
||||
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
|
||||
|
||||
4
@omniroute/opencode-plugin/package-lock.json
generated
4
@omniroute/opencode-plugin/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -23,7 +23,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -238,44 +238,18 @@ function trimLeadingDashes(value: string): string {
|
||||
*/
|
||||
export function resolveOmniRoutePluginOptions(
|
||||
opts?: OmniRoutePluginOptions
|
||||
): Required<
|
||||
Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">
|
||||
> & {
|
||||
/**
|
||||
* #6859: the UNPREFIXED provider id ("omniroute", "omniroute-preprod", …).
|
||||
* `providerId` above is auto-prefixed with "opencode-" ONLY to satisfy OC
|
||||
* 1.17.8+'s native-adapter gate ({openai, anthropic, opencode*}) — that
|
||||
* prefixed value is OC-internal and must be used ONLY for AuthHook.provider
|
||||
* and provider-registration keys (the OC config-hook top-level
|
||||
* `provider.<id>` block). `omnirouteProviderId` MUST be used everywhere an
|
||||
* identifier reaches or represents something OmniRoute's own server parses
|
||||
* (model `id` prefix, `ModelV2.providerID`, combo catalog keys in the
|
||||
* dynamic provider hook) — OmniRoute's `parseModel()` has no alias for
|
||||
* "opencode-<x>", so a prefixed id there is unrecoverable and credential
|
||||
* lookup fails with "No credentials for opencode-<x>".
|
||||
*/
|
||||
omnirouteProviderId: string;
|
||||
} & Pick<OmniRoutePluginOptions, "baseURL" | "features"> {
|
||||
const rawProviderId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY;
|
||||
const omnirouteProviderId = trimLeadingOpencodePrefix(rawProviderId);
|
||||
// OC 1.17.8+ native-adapter gate rejects providerID not in
|
||||
// {openai, anthropic, opencode*}. Silently prefix so existing
|
||||
// configs (providerId: "omniroute") keep working.
|
||||
const providerId = rawProviderId.startsWith("opencode-")
|
||||
? rawProviderId
|
||||
: `opencode-${rawProviderId}`;
|
||||
): Required<Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">> &
|
||||
Pick<OmniRoutePluginOptions, "baseURL" | "features"> {
|
||||
const providerId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY;
|
||||
const displayName =
|
||||
opts?.displayName ??
|
||||
(providerId === `opencode-${OMNIROUTE_PROVIDER_KEY}`
|
||||
? "OmniRoute"
|
||||
: `OmniRoute (${providerId})`);
|
||||
(providerId === OMNIROUTE_PROVIDER_KEY ? "OmniRoute" : `OmniRoute (${providerId})`);
|
||||
const modelCacheTtl =
|
||||
typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0
|
||||
? opts.modelCacheTtl
|
||||
: DEFAULT_MODEL_CACHE_TTL_MS;
|
||||
return {
|
||||
providerId,
|
||||
omnirouteProviderId,
|
||||
displayName,
|
||||
modelCacheTtl,
|
||||
baseURL: opts?.baseURL,
|
||||
@@ -283,18 +257,6 @@ export function resolveOmniRoutePluginOptions(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leading "opencode-" prefix (added only for the OC native-adapter
|
||||
* gate — see `resolveOmniRoutePluginOptions`) so the returned id is safe to
|
||||
* embed in anything OmniRoute's own server parses. A user-supplied
|
||||
* `providerId: "opencode-omniroute"` (already prefixed) resolves to the same
|
||||
* unprefixed "omniroute" as the default, matching `providerId`'s own
|
||||
* idempotent-prefix handling above.
|
||||
*/
|
||||
function trimLeadingOpencodePrefix(rawProviderId: string): string {
|
||||
return rawProviderId.startsWith("opencode-") ? rawProviderId.slice("opencode-".length) : rawProviderId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict parse of raw plugin options (as received from opencode.json or a
|
||||
* direct factory call) into the validated `OmniRoutePluginOptions` shape.
|
||||
@@ -746,14 +708,7 @@ export function mapRawModelToModelV2(
|
||||
const outMods = new Set(raw.output_modalities ?? ["text"]);
|
||||
|
||||
return {
|
||||
// OC's static-catalog reader parses the key on `/` to recover
|
||||
// `(providerID, modelID)`. If the raw id is already provider-prefixed
|
||||
// (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or
|
||||
// `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave
|
||||
// it as-is — double-prefixing breaks OC's lookup. Otherwise prefix with
|
||||
// the resolved `providerId` so a bare key like `claude-opus-4` parses as
|
||||
// `(omniroute, claude-opus-4)` and the credentials resolve correctly.
|
||||
id: raw.id.includes("/") ? raw.id : `${ctx.providerId}/${raw.id}`,
|
||||
id: raw.id,
|
||||
/**
|
||||
* Display name. Falls back to raw.id when no enrichment is available;
|
||||
* the caller (`createOmniRouteProviderHook`) overlays
|
||||
@@ -1211,10 +1166,6 @@ export function mapAutoComboToStaticEntry(
|
||||
typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0
|
||||
? autoCombo.max_output_tokens
|
||||
: AUTO_COMBO_FALLBACK_OUTPUT;
|
||||
// No `providerID` field on static-catalog entries — OC ignores it on the static
|
||||
// path, and stamping it on auto-combos but not on raw/combo entries was an
|
||||
// internal inconsistency. The dynamic-hook path builds its ModelV2 from the
|
||||
// individual fields below and never read this field either.
|
||||
return {
|
||||
name,
|
||||
attachment: false,
|
||||
@@ -1255,7 +1206,7 @@ export interface OmniRouteEnrichmentEntry {
|
||||
cacheWrite?: number;
|
||||
};
|
||||
/**
|
||||
* Provider alias prefix seen in `/v1/models` ids (e.g. `cc`, `gemini`).
|
||||
* Provider alias prefix seen in `/v1/models` ids (e.g. `cc`, `gemini-cli`).
|
||||
* Populated by `defaultOmniRouteEnrichmentFetcher` from
|
||||
* `/api/pricing/models` keys. Drives the `usableOnly` alias↔canonical
|
||||
* resolution.
|
||||
@@ -1263,7 +1214,7 @@ export interface OmniRouteEnrichmentEntry {
|
||||
providerAlias?: string;
|
||||
/**
|
||||
* Canonical provider id used by `/api/providers` connections (e.g.
|
||||
* `claude`, `gemini`, `kiro`). Populated from the per-provider
|
||||
* `claude`, `gemini-cli`, `kiro`). Populated from the per-provider
|
||||
* `entry.id` field inside `/api/pricing/models`.
|
||||
*/
|
||||
providerCanonical?: string;
|
||||
@@ -2076,7 +2027,7 @@ export function formatCompressionPipeline(pipeline: OmniRouteCompressionStep[]):
|
||||
export interface OmniRouteProviderConnection {
|
||||
/** Connection UUID. */
|
||||
id: string;
|
||||
/** Canonical provider id, e.g. `claude`, `gemini`, `kiro`. Matches `entry.id` in `/api/pricing/models`. */
|
||||
/** Canonical provider id, e.g. `claude`, `gemini-cli`, `kiro`. Matches `entry.id` in `/api/pricing/models`. */
|
||||
provider: string;
|
||||
/** Connection auth flavor, e.g. `apikey`, `oauth`, `cookie`. */
|
||||
authType?: string;
|
||||
@@ -2155,7 +2106,7 @@ export const defaultOmniRouteProvidersFetcher: OmniRouteProvidersFetcher = async
|
||||
* walk only the namespaced keys to derive the alias↔canonical mapping).
|
||||
*
|
||||
* Returns:
|
||||
* - `aliases`: set of alias prefixes safe to keep (e.g. `cc`, `gemini`).
|
||||
* - `aliases`: set of alias prefixes safe to keep (e.g. `cc`, `gemini-cli`).
|
||||
* - `canonicals`: set of canonical provider ids (e.g. `claude`, `kiro`).
|
||||
*
|
||||
* Callers should treat membership in EITHER set as "usable" — raw model
|
||||
@@ -2204,7 +2155,7 @@ export function usableProviderAliasSet(
|
||||
}
|
||||
// Always include every usable canonical as an alias too — handles the
|
||||
// common case where `/v1/models` ids use the canonical id directly
|
||||
// (e.g. `gemini/gemini-1.5-pro`).
|
||||
// (e.g. `gemini-cli/gemini-1.5-pro`).
|
||||
for (const canonical of usableCanonicals) aliases.add(canonical);
|
||||
return { aliases, canonicals: usableCanonicals, knownAliases };
|
||||
}
|
||||
@@ -2297,38 +2248,26 @@ export function slugifyComboName(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a combo's static-block key, provider-prefixed as `<providerId>/<slug>`
|
||||
* (e.g. `omniroute/MASTER`, `omniroute/MASTER-LIGHT`), guaranteeing uniqueness
|
||||
* across an entire static catalog. If `<providerId>/<slug>` is already present in
|
||||
* `used`, suffixes a short UUID-prefix disambiguator from `combo.id` so the second
|
||||
* combo doesn't silently overwrite the first. Mutates `used` in place by recording
|
||||
* the chosen key. Returns the final `<providerId>/<slug>` key.
|
||||
* Build a combo's static-block key (`combo/<slug>`), guaranteeing uniqueness
|
||||
* across an entire static catalog. If `<slug>` is already present in `used`,
|
||||
* suffixes a short UUID-prefix disambiguator from `combo.id` so the second
|
||||
* combo doesn't silently overwrite the first. Mutates `used` in place by
|
||||
* recording the chosen key. Returns the final `combo/<...>` key.
|
||||
*
|
||||
* NOTE: the key MUST carry the OWNING provider prefix (`omniroute/…`), never a
|
||||
* `combo/` namespace — OpenCode parses model IDs on `/` to extract the provider,
|
||||
* so `combo/MASTER` would resolve provider=`combo` (no credentials) and fail with
|
||||
* "Unable to determine provider", whereas `omniroute/MASTER` resolves provider=
|
||||
* `omniroute` and the openai-compatible adapter strips the prefix and sends the
|
||||
* bare slug upstream, which the server resolves via getComboByName. See PR #4184.
|
||||
*
|
||||
* Falls back to `<providerId>/<id>` when the friendly name slugifies to the empty
|
||||
* Falls back to `combo/<id>` when the friendly name slugifies to the empty
|
||||
* string (e.g. a combo named just punctuation).
|
||||
*/
|
||||
export function buildComboKey(
|
||||
combo: OmniRouteRawCombo,
|
||||
used: Set<string>,
|
||||
providerId: string
|
||||
): string {
|
||||
export function buildComboKey(combo: OmniRouteRawCombo, used: Set<string>): string {
|
||||
const friendlyName = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
|
||||
let slug = slugifyComboName(friendlyName);
|
||||
if (slug.length === 0) slug = combo.id;
|
||||
let key = `${providerId}/${slug}`;
|
||||
let key = `combo/${slug}`;
|
||||
if (used.has(key)) {
|
||||
const tail = combo.id.split("-")[0] ?? combo.id;
|
||||
key = `${providerId}/${slug}-${tail}`;
|
||||
key = `combo/${slug}-${tail}`;
|
||||
// Defensive: in the (impossible) event the disambiguated key also
|
||||
// collides, append the full id.
|
||||
if (used.has(key)) key = `${providerId}/${slug}-${combo.id}`;
|
||||
if (used.has(key)) key = `combo/${slug}-${combo.id}`;
|
||||
}
|
||||
used.add(key);
|
||||
return key;
|
||||
@@ -2691,8 +2630,7 @@ export function createOmniRouteProviderHook(
|
||||
if (canonicalDedup.has(entry.id)) continue;
|
||||
if (usable && !isUsableRawModelId(entry.id, usable, rawEnrichment)) continue;
|
||||
const model = mapRawModelToModelV2(entry, {
|
||||
// #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`.
|
||||
providerId: resolved.omnirouteProviderId,
|
||||
providerId: resolved.providerId,
|
||||
baseURL,
|
||||
apiFormat: resolved.features?.apiFormat,
|
||||
});
|
||||
@@ -2713,12 +2651,7 @@ export function createOmniRouteProviderHook(
|
||||
);
|
||||
applyProviderTag(model, tagEntry);
|
||||
}
|
||||
// OC's static-catalog reader parses the key on `/` to recover
|
||||
// (providerID, modelID). `mapRawModelToModelV2` already stamps the
|
||||
// prefixed id on `model.id` (e.g. `omniroute/claude-primary`), so we
|
||||
// must key by `model.id` — not by the raw `entry.id` which would be
|
||||
// a bare slug and parse as `providerID=slug, modelID=""`.
|
||||
models[model.id] = model;
|
||||
models[entry.id] = model;
|
||||
}
|
||||
|
||||
// Default compression combo (used to decorate ALL combo names when
|
||||
@@ -2857,8 +2790,7 @@ export function createOmniRouteProviderHook(
|
||||
const mapped = mapComboToModelV2(
|
||||
combo,
|
||||
memberEntries,
|
||||
// #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`.
|
||||
resolved.omnirouteProviderId,
|
||||
resolved.providerId,
|
||||
baseURL,
|
||||
features.apiFormat
|
||||
);
|
||||
@@ -2869,6 +2801,18 @@ export function createOmniRouteProviderHook(
|
||||
// models with curated names).
|
||||
applyEnrichment(mapped, rawEnrichment.get(combo.id));
|
||||
|
||||
// `Combo: ` prefix surfaces the combo nature in OC's model picker.
|
||||
// Idempotent guard covers the case where enrichment overwrote
|
||||
// mapped.name with an already-prefixed string. Mirrors the
|
||||
// static-hook Combo:-prefix decoration.
|
||||
if (!mapped.name.startsWith("Combo: ")) {
|
||||
mapped.name = `Combo: ${mapped.name}`;
|
||||
}
|
||||
|
||||
// Optionally decorate combo name with its compression pipeline.
|
||||
// Only fires when features.compressionMetadata: true, OmniRoute
|
||||
// returned at least one default compression combo, AND the
|
||||
// combo has resolvable members — claiming compression on an
|
||||
// unroutable combo would mislead the picker.
|
||||
if (hasMembers && defaultCompression && defaultCompression.pipeline.length > 0) {
|
||||
const tag = formatCompressionPipeline(defaultCompression.pipeline);
|
||||
@@ -2877,38 +2821,18 @@ export function createOmniRouteProviderHook(
|
||||
}
|
||||
}
|
||||
|
||||
// #6859: server-facing key — NOT the OC-gate-prefixed `resolved.providerId`.
|
||||
const comboKey = buildComboKey(combo, usedComboKeys, resolved.omnirouteProviderId);
|
||||
const comboKey = buildComboKey(combo, usedComboKeys);
|
||||
|
||||
// Collision policy: combos win. Warn ONCE per (cacheKey, comboKey)
|
||||
// when overwriting a same-key raw model so the operator can spot
|
||||
// the unusual naming choice without log spam. Suppress the warning
|
||||
// when the collision is the intentional dedup pattern (combo.name
|
||||
// exactly matches an existing raw model's id) — /v1/models
|
||||
// pre-mirrors combos as raw entries and the operator's intent is
|
||||
// always "combo wins" in that case.
|
||||
// the unusual naming choice without log spam.
|
||||
if (Object.prototype.hasOwnProperty.call(models, comboKey)) {
|
||||
const existing = models[comboKey];
|
||||
// Intentional dedup: `/v1/models` pre-mirrors combos as raw
|
||||
// entries, so the bare combo name appears as the model id in
|
||||
// `rawModels`. After our prefixing the existing entry's id is
|
||||
// `${providerId}/${raw.id}` — the combo name is a substring of
|
||||
// that prefixed id (or, for already-prefixed raw models, the
|
||||
// exact id). Use `endsWith` to avoid matching substrings of
|
||||
// unrelated prefixed ids.
|
||||
const isIntentionalDedup =
|
||||
existing &&
|
||||
combo.name &&
|
||||
combo.name.trim().length > 0 &&
|
||||
(existing.id === combo.name.trim() || existing.id.endsWith(`/${combo.name.trim()}`));
|
||||
if (!isIntentionalDedup) {
|
||||
const dedupeKey = `${cacheKey}::${comboKey}`;
|
||||
if (!collisionWarned.has(dedupeKey)) {
|
||||
collisionWarned.add(dedupeKey);
|
||||
console.warn(
|
||||
`[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.`
|
||||
);
|
||||
}
|
||||
const dedupeKey = `${cacheKey}::${comboKey}`;
|
||||
if (!collisionWarned.has(dedupeKey)) {
|
||||
collisionWarned.add(dedupeKey);
|
||||
console.warn(
|
||||
`[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.`
|
||||
);
|
||||
}
|
||||
}
|
||||
models[comboKey] = mapped;
|
||||
@@ -2980,8 +2904,7 @@ export function createOmniRouteProviderHook(
|
||||
},
|
||||
status: "active",
|
||||
release_date: "",
|
||||
// #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`.
|
||||
providerID: resolved.omnirouteProviderId,
|
||||
providerID: resolved.providerId,
|
||||
options: {},
|
||||
headers: {},
|
||||
};
|
||||
@@ -3208,6 +3131,7 @@ export function sanitizeGeminiToolSchemas(payload: unknown): unknown {
|
||||
* `gemini-2.5-flash`, etc.)
|
||||
* - `models/gemini-…` (Google Generative AI canonical id form)
|
||||
* - `google-vertex/gemini-…` (OpenCode + AI-SDK Vertex routing prefix)
|
||||
* - `gemini-cli/…` (real OmniRoute alias surfaced on b35 prod `/v1/models`)
|
||||
*
|
||||
* Liberal by design: a false positive (cleaning a payload that didn't
|
||||
* need cleaning) costs only a structuredClone + one walk; a false negative
|
||||
@@ -3422,18 +3346,8 @@ function normaliseModalities(raw: unknown): OmniRouteModalityKind[] {
|
||||
}
|
||||
|
||||
export interface OmniRouteStaticModelEntry {
|
||||
/** Owning provider id. SHOULD match the parent `provider.<id>` key so OC's
|
||||
* static-catalog reader resolves credentials via `providerID` instead of
|
||||
* parsing the model key on `/`. Optional: OC's schema validator may
|
||||
* reject the entire provider block when this field is present but the
|
||||
* model KEY already carries the provider prefix (e.g. `omniroute/MASTER`),
|
||||
* since the prefix makes the field redundant and the field is not part of
|
||||
* OC's expected schema. We omit it from entries and rely on the prefix
|
||||
* on the KEY alone. See PR #4184. */
|
||||
providerID?: string;
|
||||
/** Display label rendered in OC's model picker. Defaults to the model id. */
|
||||
name: string;
|
||||
|
||||
/** ISO date the model was released. Surfaces in OC's model card when present. */
|
||||
release_date?: string;
|
||||
/** Model accepts image / file attachments. */
|
||||
@@ -3631,12 +3545,6 @@ export function buildStaticProviderEntry(
|
||||
if (!displayName.startsWith(prefix)) displayName = `${prefix}${displayName}`;
|
||||
}
|
||||
}
|
||||
// OC's static-catalog schema doesn't expect a `providerID` field on
|
||||
// individual entries — the parent block ID is the provider. Adding
|
||||
// unknown fields here can cause OC's schema validator to reject the
|
||||
// entire provider block, hiding ALL models. The provider prefix on the
|
||||
// model KEY (e.g. `omniroute/claude-opus-4`) is what OC uses to recover
|
||||
// (providerID, modelID) when the user selects a model.
|
||||
const entry: OmniRouteStaticModelEntry = { name: displayName };
|
||||
|
||||
const attachment = caps.attachment ?? caps.vision;
|
||||
@@ -3700,12 +3608,7 @@ export function buildStaticProviderEntry(
|
||||
entry.release_date = raw.release_date;
|
||||
}
|
||||
|
||||
// OC's static-catalog reader parses each key on `/` and rejects the
|
||||
// entire provider block if ANY key resolves to a parsed providerID that
|
||||
// has no corresponding provider block. So bare keys (no `/`) MUST be
|
||||
// prefixed with the resolved providerId. Already-prefixed keys
|
||||
// (e.g. `cc/claude-opus-4-7`) are left as-is to avoid double-prefixing.
|
||||
models[raw.id.includes("/") ? raw.id : `${opts.providerId}/${raw.id}`] = entry;
|
||||
models[raw.id] = entry;
|
||||
}
|
||||
|
||||
// Combo entries → stripped LCD shape. Each combo is keyed as
|
||||
@@ -3814,11 +3717,12 @@ export function buildStaticProviderEntry(
|
||||
const hasMembers = memberEntries.length > 0;
|
||||
const friendlyName =
|
||||
combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
|
||||
// `Combo: ` prefix surfaces the combo nature in OC's model picker — the
|
||||
// catalog key (`combo/<slug>`) is already namespaced, but the picker
|
||||
// shows `name`, so prefix the display string too.
|
||||
const prefixedName = `Combo: ${friendlyName}`;
|
||||
const displayName =
|
||||
hasMembers && compressionSuffix ? `${friendlyName} ${compressionSuffix}` : friendlyName;
|
||||
// See the raw-model entry comment above — `providerID` on entries is
|
||||
// not part of OC's static-catalog schema; the parent block ID is the
|
||||
// provider and the KEY prefix (`omniroute/<slug>`) is what OC parses.
|
||||
hasMembers && compressionSuffix ? `${prefixedName}${compressionSuffix}` : prefixedName;
|
||||
const entry: OmniRouteStaticModelEntry = { name: displayName };
|
||||
|
||||
if (hasMembers) {
|
||||
@@ -3886,12 +3790,12 @@ export function buildStaticProviderEntry(
|
||||
entry.tool_call = false;
|
||||
}
|
||||
|
||||
// Key under bare slug (e.g. `claude-primary`) — no `combo/` prefix
|
||||
// because OpenCode parses model IDs on `/` and would treat
|
||||
// `combo/MASTER` as provider=`combo`. Slug collisions across
|
||||
// Key under `combo/<slug>` (e.g. `combo/claude-primary`) so the
|
||||
// namespace cleanly separates combos from raw provider/model pairs
|
||||
// and so the key is copy/paste-friendly. Slug collisions across
|
||||
// combos are disambiguated with a short UUID-prefix suffix; see
|
||||
// `buildComboKey` for the policy.
|
||||
models[buildComboKey(combo, usedComboKeys, opts.providerId)] = entry;
|
||||
models[buildComboKey(combo, usedComboKeys)] = entry;
|
||||
|
||||
// Make this combo's resolved entry available to parent combos
|
||||
// that reference it via combo-ref. Use the friendly name since
|
||||
@@ -4439,24 +4343,8 @@ export function createOmniRouteConfigHook(
|
||||
authJson = undefined;
|
||||
}
|
||||
|
||||
// Try both prefixed (e.g. opencode-omniroute) and unprefixed (e.g. omniroute)
|
||||
// keys so a user who ran `/connect omniroute` before the auto-prefix fix
|
||||
// does not need to re-auth. Also handles dual-key for auth.json entries
|
||||
// written by a newer OC dispatcher with the prefixed key.
|
||||
const bareKey = resolved.providerId.startsWith("opencode-")
|
||||
? resolved.providerId.slice("opencode-".length)
|
||||
: resolved.providerId;
|
||||
const lookupKeys = [resolved.providerId];
|
||||
if (bareKey !== resolved.providerId) lookupKeys.push(bareKey);
|
||||
let entry;
|
||||
for (const k of lookupKeys) {
|
||||
const e = authJson?.[k];
|
||||
if (e?.type === "api" && typeof e.key === "string" && e.key.length > 0) {
|
||||
entry = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const apiKey = entry?.type === "api" && typeof entry.key === "string" ? entry.key : "";
|
||||
const entry = authJson?.[resolved.providerId] as AuthJsonApiEntry | undefined;
|
||||
const apiKey = entry && entry.type === "api" && typeof entry.key === "string" ? entry.key : "";
|
||||
|
||||
if (!apiKey) {
|
||||
// (c) no apiKey — silent no-op (with debug breadcrumb). The operator
|
||||
|
||||
@@ -13,12 +13,12 @@ import { createOmniRouteAuthHook } from "../src/index.js";
|
||||
|
||||
test("createOmniRouteAuthHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.equal(hook.provider, "opencode-omniroute");
|
||||
assert.equal(hook.provider, "omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: custom providerId binds to hook.provider (multi-instance)", () => {
|
||||
const hook = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(hook.provider, "opencode-omniroute-preprod");
|
||||
assert.equal(hook.provider, "omniroute-preprod");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: methods[0] is type 'api' with label including displayName", () => {
|
||||
@@ -30,7 +30,7 @@ test("createOmniRouteAuthHook: methods[0] is type 'api' with label including dis
|
||||
assert.equal(m.label, "OmniRoute API Key");
|
||||
|
||||
const custom = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (opencode-omniroute-preprod) API Key");
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (omniroute-preprod) API Key");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: prompts[0] uses key='apiKey' per @opencode-ai/plugin contract", () => {
|
||||
|
||||
@@ -447,13 +447,13 @@ test("models() returns combo entries merged into the map", async () => {
|
||||
|
||||
// 3 raw models + 1 combo = 4 entries
|
||||
assert.equal(Object.keys(out).length, 4);
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
assert.ok(out["omniroute/claude-secondary"]);
|
||||
assert.ok(out["omniroute/gemini-3-flash"]);
|
||||
assert.ok(out["omniroute/claude-tier"]);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["claude-secondary"]);
|
||||
assert.ok(out["gemini-3-flash"]);
|
||||
assert.ok(out["combo/claude-tier"]);
|
||||
|
||||
const combo = out["omniroute/claude-tier"];
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
const combo = out["combo/claude-tier"];
|
||||
assert.equal(combo.name, "Combo: Claude Tier");
|
||||
assert.equal(combo.providerID, "omniroute");
|
||||
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
|
||||
assert.equal(combo.limit.context, 100_000);
|
||||
@@ -478,11 +478,11 @@ test("models(): combo with unknown member ids degrades to all-false LCD posture"
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["omniroute/phantom-combo"]);
|
||||
assert.ok(out["combo/phantom-combo"]);
|
||||
// With zero resolvable members, LCD = all-false (defensive posture).
|
||||
assert.equal(out["omniroute/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["omniroute/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["omniroute/phantom-combo"].limit.context, 0);
|
||||
assert.equal(out["combo/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["combo/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["combo/phantom-combo"].limit.context, 0);
|
||||
});
|
||||
|
||||
test("models(): hidden combos are excluded from the map", async () => {
|
||||
@@ -505,11 +505,11 @@ test("models(): hidden combos are excluded from the map", async () => {
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["omniroute/visible"]);
|
||||
assert.ok(!out["omniroute/hidden"], "hidden combo must be omitted");
|
||||
assert.ok(out["combo/visible"]);
|
||||
assert.ok(!out["combo/hidden"], "hidden combo must be omitted");
|
||||
});
|
||||
|
||||
test("models(): combo name exactly matches raw model id → raw deleted, raw deleted, no warn", async () => {
|
||||
test("models(): combo name exactly matches raw model id → raw deleted, combo lives at combo/ key, no warn", async () => {
|
||||
// Combo.name === raw model id triggers the dedup deletion. This mirrors
|
||||
// the real OmniRoute payload where /v1/models pre-mirrors combos as
|
||||
// no-slash raw entries whose ids match /api/combos friendly names.
|
||||
@@ -529,9 +529,10 @@ test("models(): combo name exactly matches raw model id → raw deleted, raw del
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
|
||||
// Raw model replaced by combo of the same key; combo now lives at the bare slug.
|
||||
assert.ok(out["omniroute/claude-primary"], "combo surfaces under prefixed key");
|
||||
assert.equal(out["omniroute/claude-primary"].name, "claude-primary");
|
||||
// Raw model deleted by combo-name dedup; combo surfaces under combo/<slug>.
|
||||
assert.equal(out["claude-primary"], undefined, "raw deleted by combo-name dedup");
|
||||
assert.ok(out["combo/claude-primary"], "combo surfaces under combo/ namespace");
|
||||
assert.equal(out["combo/claude-primary"].name, "Combo: claude-primary");
|
||||
|
||||
// No collision warning fires — dedup makes keys disjoint.
|
||||
const collisionWarns = warnings.filter((w) => {
|
||||
@@ -542,7 +543,7 @@ test("models(): combo name exactly matches raw model id → raw deleted, raw del
|
||||
});
|
||||
|
||||
test("models(): two combos with same slug → second gets disambiguator suffix", async () => {
|
||||
// Both combos slug to `claude` — second must get `claude-<id-prefix>`.
|
||||
// Both combos slug to `claude` — second must get `combo/claude-<id-prefix>`.
|
||||
const combos: OmniRouteRawCombo[] = [
|
||||
{
|
||||
id: "uuid-a",
|
||||
@@ -565,8 +566,8 @@ test("models(): two combos with same slug → second gets disambiguator suffix",
|
||||
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
// First combo gets the bare slug; second gets disambiguated.
|
||||
assert.ok(out["omniroute/claude"], "first combo at prefixed slug");
|
||||
assert.ok(out["omniroute/claude-uuid"], "second combo disambiguated by id prefix");
|
||||
assert.ok(out["combo/claude"], "first combo at bare slug");
|
||||
assert.ok(out["combo/claude-uuid"], "second combo disambiguated by id prefix");
|
||||
});
|
||||
|
||||
test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => {
|
||||
@@ -583,8 +584,8 @@ test("models(): combos fetch fails → falls back to models-only, warn emitted,
|
||||
|
||||
// Catalog includes the models but NOT any combo entries.
|
||||
assert.equal(Object.keys(out).length, 2);
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
assert.ok(out["omniroute/claude-secondary"]);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["claude-secondary"]);
|
||||
|
||||
// Soft-fail warning surfaced.
|
||||
const softFail = warnings.find((w) => {
|
||||
@@ -609,7 +610,7 @@ test("models(): combos cached + reused within TTL (one combo fetch per TTL windo
|
||||
const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL");
|
||||
assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL");
|
||||
assert.ok(second["omniroute/claude-tier"]);
|
||||
assert.ok(second["combo/claude-tier"]);
|
||||
});
|
||||
|
||||
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
|
||||
@@ -701,7 +702,7 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
const masterLight = out["omniroute/master-light"];
|
||||
const masterLight = out["combo/master-light"];
|
||||
assert.ok(masterLight, "MASTER-LIGHT entry must exist");
|
||||
assert.equal(
|
||||
masterLight.limit.context,
|
||||
|
||||
@@ -203,7 +203,7 @@ function makeInput(initialProvider: Record<string, unknown> = {}): Config {
|
||||
|
||||
test("config: with valid auth.json + apiKey + baseURL → mutates input.provider[id] with stripped models block", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" },
|
||||
omniroute: { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -217,8 +217,8 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider["opencode-omniroute"];
|
||||
assert.ok(entry, "input.provider['opencode-omniroute'] set");
|
||||
const entry = provider.omniroute;
|
||||
assert.ok(entry, "input.provider.omniroute set");
|
||||
assert.equal(entry.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(entry.name, "OmniRoute");
|
||||
assert.equal(entry.options.baseURL, "https://or.example.com/v1");
|
||||
@@ -227,7 +227,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
// Stripped per-model shape: name + cap flags + modalities + (optional)
|
||||
// cost. OC's SDK static schema accepts only `limit.{context,output}` —
|
||||
// `limit.input` is NOT in the SDK shape and gets dropped silently.
|
||||
const claude = entry.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
const claude = entry.models["claude-sonnet-4-6"];
|
||||
assert.ok(claude, "claude model surfaced");
|
||||
assert.equal(claude.name, "claude-sonnet-4-6");
|
||||
assert.equal(claude.attachment, true);
|
||||
@@ -246,75 +246,16 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
assert.deepEqual(claude.modalities?.input, ["text", "image"]);
|
||||
assert.deepEqual(claude.modalities?.output, ["text"]);
|
||||
|
||||
// Combo surfaces under bare key + LCD'd
|
||||
// Combo surfaces under `combo/<friendly-name>` namespace + LCD'd
|
||||
// (gemini's reasoning=false → combo reasoning=false).
|
||||
const combo = entry.models["opencode-omniroute/claude-tier"];
|
||||
assert.ok(combo, "combo surfaced under bare key");
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
const combo = entry.models["combo/claude-tier"];
|
||||
assert.ok(combo, "combo surfaced under combo/ namespace");
|
||||
assert.equal(combo.name, "Combo: Claude Tier");
|
||||
assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false");
|
||||
assert.equal(combo.tool_call, true);
|
||||
assert.equal(combo.limit?.context, 200_000, "LCD: min(200_000, 1_000_000)");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 1b. Dual-key fallback (#5027) — auth.json stored under the BARE providerId
|
||||
// (pre-auto-prefix login) must still resolve when the active providerId is
|
||||
// prefixed (`opencode-omniroute`). Without the fallback the lookup misses
|
||||
// the stored key and the user is forced to re-auth.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: auth.json under bare key (pre-prefix login) resolves via dual-key fallback", async () => {
|
||||
// Stored under bare `omniroute` (the key OC wrote before the auto-prefix fix),
|
||||
// but the resolved providerId is now `opencode-omniroute`.
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-bare-1", baseURL: "https://or.example.com/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" }, // resolves to opencode-omniroute internally
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider["opencode-omniroute"];
|
||||
assert.ok(entry, "provider entry published from bare-key apiKey");
|
||||
assert.equal(entry.options.apiKey, "sk-bare-1", "apiKey resolved from the bare auth.json key");
|
||||
assert.equal(entry.options.baseURL, "https://or.example.com/v1");
|
||||
});
|
||||
|
||||
test("config: prefixed key wins over bare key when both present (dual-key precedence)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-prefixed", baseURL: "https://pref.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-bare", baseURL: "https://bare.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(
|
||||
entry.options.apiKey,
|
||||
"sk-prefixed",
|
||||
"prefixed key takes precedence (looked up first)"
|
||||
);
|
||||
assert.equal(entry.options.baseURL, "https://pref.example/v1");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Missing auth.json → no-op, no throw, no mutation
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -382,7 +323,7 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
models: { "manual-model": { name: "manual-model" } },
|
||||
};
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -392,11 +333,11 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput({ "opencode-omniroute": manual });
|
||||
const input = makeInput({ omniroute: manual });
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, unknown> }).provider;
|
||||
assert.equal(provider["opencode-omniroute"], manual, "manual override preserved by reference");
|
||||
assert.equal(provider.omniroute, manual, "manual override preserved by reference");
|
||||
assert.equal(fetcher.callCount(), 0, "no fetch — short-circuited before I/O");
|
||||
assert.equal(readAuthJson.callCount(), 0, "no auth.json read either");
|
||||
assert.ok(
|
||||
@@ -411,7 +352,7 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
|
||||
test("config: fetchers throw → warn + emit stub entry with models: {}", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = throwingCombosFetcher();
|
||||
@@ -427,9 +368,8 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry, "stub provider entry published even when fetchers fail");
|
||||
assert.equal(entry.npm, "@ai-sdk/openai-compatible");
|
||||
assert.deepEqual(entry.models, {}, "models stub is empty object");
|
||||
@@ -452,7 +392,7 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
|
||||
|
||||
test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = throwingCombosFetcher();
|
||||
@@ -465,16 +405,12 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry);
|
||||
const ids = Object.keys(entry.models).sort();
|
||||
assert.deepEqual(ids, [
|
||||
"opencode-omniroute/claude-sonnet-4-6",
|
||||
"opencode-omniroute/gemini-3-flash",
|
||||
]);
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"], undefined, "no combo entry");
|
||||
assert.deepEqual(ids, ["claude-sonnet-4-6", "gemini-3-flash"]);
|
||||
assert.equal(entry.models["combo-claude-tier"], undefined, "no combo entry");
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"combos-fetch breadcrumb emitted"
|
||||
@@ -487,7 +423,7 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
|
||||
test("config: baseURL from auth.json takes precedence when opts.baseURL absent", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -501,15 +437,14 @@ test("config: baseURL from auth.json takes precedence when opts.baseURL absent",
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://creds.example/v1");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.options.baseURL, "https://creds.example/v1");
|
||||
});
|
||||
|
||||
test("config: opts.baseURL wins over auth.json's stored baseURL", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -523,15 +458,14 @@ test("config: opts.baseURL wins over auth.json's stored baseURL", async () => {
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://opts.example/v1");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.options.baseURL, "https://opts.example/v1");
|
||||
});
|
||||
|
||||
test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test" }, // NO baseURL on the credential
|
||||
omniroute: { type: "api", key: "sk-test" }, // NO baseURL on the credential
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -559,12 +493,12 @@ test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op",
|
||||
|
||||
test("config: multi-instance — two plugins with different providerIds publish to their own keys without collision", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute-prod": {
|
||||
"omniroute-prod": {
|
||||
type: "api",
|
||||
key: "sk-prod",
|
||||
baseURL: "https://prod.example/v1",
|
||||
},
|
||||
"opencode-omniroute-preprod": {
|
||||
"omniroute-preprod": {
|
||||
type: "api",
|
||||
key: "sk-preprod",
|
||||
baseURL: "https://preprod.example/v1",
|
||||
@@ -588,18 +522,15 @@ test("config: multi-instance — two plugins with different providerIds publish
|
||||
await hookB(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
assert.ok(provider["opencode-omniroute-prod"], "prod block present");
|
||||
assert.ok(provider["opencode-omniroute-preprod"], "preprod block present");
|
||||
assert.equal(provider["opencode-omniroute-prod"].options.apiKey, "sk-prod");
|
||||
assert.equal(provider["opencode-omniroute-preprod"].options.apiKey, "sk-preprod");
|
||||
assert.equal(provider["opencode-omniroute-prod"].options.baseURL, "https://prod.example/v1");
|
||||
assert.equal(
|
||||
provider["opencode-omniroute-preprod"].options.baseURL,
|
||||
"https://preprod.example/v1"
|
||||
);
|
||||
assert.ok(provider["omniroute-prod"], "prod block present");
|
||||
assert.ok(provider["omniroute-preprod"], "preprod block present");
|
||||
assert.equal(provider["omniroute-prod"].options.apiKey, "sk-prod");
|
||||
assert.equal(provider["omniroute-preprod"].options.apiKey, "sk-preprod");
|
||||
assert.equal(provider["omniroute-prod"].options.baseURL, "https://prod.example/v1");
|
||||
assert.equal(provider["omniroute-preprod"].options.baseURL, "https://preprod.example/v1");
|
||||
assert.notEqual(
|
||||
provider["opencode-omniroute-prod"],
|
||||
provider["opencode-omniroute-preprod"],
|
||||
provider["omniroute-prod"],
|
||||
provider["omniroute-preprod"],
|
||||
"blocks are distinct references"
|
||||
);
|
||||
});
|
||||
@@ -611,7 +542,7 @@ test("config: multi-instance — two plugins with different providerIds publish
|
||||
|
||||
test("config + provider share cache: second call uses cached fetch result (single fetch per TTL)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -643,7 +574,7 @@ test("config + provider share cache: second call uses cached fetch result (singl
|
||||
|
||||
test("provider → config order also dedupes (cache populated by provider, consumed by config)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -707,7 +638,6 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro
|
||||
"cost",
|
||||
"limit",
|
||||
"modalities",
|
||||
"providerID",
|
||||
]);
|
||||
for (const [id, entry] of Object.entries(block.models)) {
|
||||
for (const key of Object.keys(entry)) {
|
||||
@@ -723,7 +653,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro
|
||||
}
|
||||
|
||||
// Sanity: claude entry has all expected stripped fields.
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
assert.equal(typeof claude.name, "string");
|
||||
assert.equal(typeof claude.attachment, "boolean");
|
||||
assert.equal(typeof claude.reasoning, "boolean");
|
||||
@@ -748,8 +678,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["opencode-omniroute/claude-tier"], undefined);
|
||||
assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
|
||||
assert.equal(block.models["combo-claude-tier"], undefined);
|
||||
assert.ok(block.models["claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -765,7 +695,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
assert.deepEqual(claude.modalities?.input, ["text", "image"]);
|
||||
assert.deepEqual(claude.modalities?.output, ["text"]);
|
||||
});
|
||||
@@ -779,7 +709,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", ()
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
assert.equal((claude.limit as Record<string, unknown>).input, undefined);
|
||||
assert.equal(typeof claude.limit?.context, "number");
|
||||
assert.equal(typeof claude.limit?.output, "number");
|
||||
@@ -807,7 +737,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", ()
|
||||
"sk-test",
|
||||
enrichment
|
||||
);
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
assert.equal(claude.cost?.input, 3);
|
||||
assert.equal(claude.cost?.output, 15);
|
||||
assert.equal(claude.cost?.cache_read, 0.3);
|
||||
@@ -828,8 +758,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["opencode-omniroute/claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["opencode-omniroute/gemini-3-flash"].release_date, undefined);
|
||||
assert.equal(block.models["claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["gemini-3-flash"].release_date, undefined);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => {
|
||||
@@ -858,7 +788,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const combo = block.models["opencode-omniroute/mixed-tier"];
|
||||
const combo = block.models["combo/mixed-tier"];
|
||||
assert.ok(combo, "combo emitted under slug key");
|
||||
// claude has text+image, text-only has text → intersection drops image.
|
||||
assert.deepEqual(combo.modalities?.input, ["text"]);
|
||||
@@ -882,7 +812,7 @@ test("OmniRoutePlugin factory exposes config hook alongside auth + provider", as
|
||||
|
||||
test("config: auth.json entry of wrong type (oauth) → no-op", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "oauth", refresh: "r", access: "a", expires: 0 },
|
||||
omniroute: { type: "oauth", refresh: "r", access: "a", expires: 0 },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -919,7 +849,7 @@ test("config: readAuthJson throws → treat as missing file (silent fallback)",
|
||||
|
||||
test("config: initialises input.provider when undefined", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -934,7 +864,7 @@ test("config: initialises input.provider when undefined", async () => {
|
||||
await hook(input);
|
||||
const provider = (input as { provider?: Record<string, unknown> }).provider;
|
||||
assert.ok(provider, "provider bag initialised");
|
||||
assert.ok(provider!["opencode-omniroute"]);
|
||||
assert.ok(provider!.omniroute);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -944,7 +874,7 @@ test("config: initialises input.provider when undefined", async () => {
|
||||
|
||||
test("config: enrichment fetched + name overlaid on raw-model entries", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -963,20 +893,19 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
// Combo names still come from /api/combos — enrichment overlay does NOT touch combos.
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(entry.models["combo/claude-tier"].name, "Combo: Claude Tier");
|
||||
assert.equal(enrichmentFetcher.callCount(), 1);
|
||||
});
|
||||
|
||||
test("config: features.enrichment=false skips enrichment fetch + keeps raw-id names", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -994,21 +923,16 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry);
|
||||
assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained");
|
||||
});
|
||||
|
||||
test("config: enrichment fetcher throws → soft-fail (warn + raw-id static catalog)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1022,15 +946,10 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry, "static block still published on enrichment failure");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained");
|
||||
assert.equal(enrichmentFetcher.callCount(), 1);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")),
|
||||
@@ -1071,7 +990,7 @@ const MODEL_NV_LLAMA: OmniRouteRawModelEntry = {
|
||||
|
||||
test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CC_OPUS, MODEL_NV_LLAMA]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1093,9 +1012,8 @@ test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"], "claude kept");
|
||||
assert.ok(entry.models["nvidia/llama-3-70b"], "nvidia kept (filter off)");
|
||||
assert.equal(providersFetcher.callCount(), 0, "providers fetch not called when feature off");
|
||||
@@ -1103,7 +1021,7 @@ test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
|
||||
test("config: usableOnly=true → drops models for non-usable providers, keeps usable + unknown", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([
|
||||
MODEL_CC_OPUS,
|
||||
@@ -1143,9 +1061,8 @@ test("config: usableOnly=true → drops models for non-usable providers, keeps u
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"], "claude kept (active)");
|
||||
assert.equal(entry.models["nvidia/llama-3-70b"], undefined, "nvidia dropped (error status)");
|
||||
assert.ok(entry.models["agentrouter/synthetic-1"], "unknown prefix kept (subtract-filter)");
|
||||
@@ -1154,7 +1071,7 @@ test("config: usableOnly=true → drops models for non-usable providers, keeps u
|
||||
|
||||
test("config: usableOnly=true + providers fetch fails → soft-fail keeps everything", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CC_OPUS, MODEL_NV_LLAMA]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1175,9 +1092,8 @@ test("config: usableOnly=true + providers fetch fails → soft-fail keeps everyt
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"]);
|
||||
assert.ok(entry.models["nvidia/llama-3-70b"], "soft-fail keeps both");
|
||||
assert.ok(
|
||||
@@ -1188,7 +1104,7 @@ test("config: usableOnly=true + providers fetch fails → soft-fail keeps everyt
|
||||
|
||||
test("config: diskCache hydrates stale snapshot when /v1/models throws", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1225,15 +1141,11 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"stale snapshot hydrated into static block"
|
||||
);
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry.models["claude-sonnet-4-6"], "stale snapshot hydrated into static block");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6 (cached)",
|
||||
"stale enrichment also reused"
|
||||
);
|
||||
@@ -1246,7 +1158,7 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
|
||||
test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1278,10 +1190,9 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
await configHook(input);
|
||||
assert.equal(enrichmentFetcher.callCount(), 1, "config reused cached enrichment");
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1292,7 +1203,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
|
||||
test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-model names", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -1311,9 +1222,9 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
"gemini-3-flash",
|
||||
{
|
||||
name: "Gemini 3 Flash",
|
||||
providerAlias: "gemini",
|
||||
providerCanonical: "gemini",
|
||||
providerDisplayName: "Gemini",
|
||||
providerAlias: "gemini-cli",
|
||||
providerCanonical: "gemini-cli",
|
||||
providerDisplayName: "Gemini-cli",
|
||||
},
|
||||
],
|
||||
])
|
||||
@@ -1327,22 +1238,18 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry);
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini-cli - Gemini 3 Flash");
|
||||
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(entry.models["combo/claude-tier"].name, "Combo: Claude Tier");
|
||||
});
|
||||
|
||||
test("config: providerTag=false suppresses the suffix", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1360,11 +1267,10 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6",
|
||||
"enriched name kept, provider tag suppressed"
|
||||
);
|
||||
@@ -1372,7 +1278,7 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
|
||||
test("config: providerTag falls back to UPPER(alias) when providerDisplayName missing", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1393,15 +1299,14 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1420,15 +1325,14 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1447,24 +1351,16 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
|
||||
const inputA = makeInput();
|
||||
await hook(inputA);
|
||||
const entryA = (inputA as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryA.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
const entryA = (inputA as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entryA.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
|
||||
// Second invocation (cache hit) — name must still be single-suffixed.
|
||||
const inputB = makeInput();
|
||||
await hook(inputB);
|
||||
const entryB = (inputB as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryB.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
const entryB = (inputB as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entryB.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1516,7 +1412,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros
|
||||
);
|
||||
// Pre-fix: Parent would advertise 200_000 (only raw-big counted).
|
||||
// Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck).
|
||||
const parent = block.models["opencode-omniroute/parent"];
|
||||
const parent = block.models["combo/parent"];
|
||||
assert.ok(parent, "Parent combo must be in the static catalog");
|
||||
assert.equal(parent.limit?.context, 8_000);
|
||||
});
|
||||
|
||||
@@ -376,8 +376,7 @@ test("provider hook: enrichment fetcher called when features.enrichment !== fals
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "enrichment fetcher called once");
|
||||
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId.
|
||||
const m = out["omniroute/claude-sonnet-4-6"];
|
||||
const m = out["claude-sonnet-4-6"];
|
||||
assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied");
|
||||
assert.equal(m.cost.input, 3, "enrichment pricing applied");
|
||||
assert.equal(m.cost.output, 15);
|
||||
@@ -402,11 +401,7 @@ test("provider hook: enrichment fetcher NOT called when features.enrichment:fals
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 0, "enrichment fetcher NOT called when gated off");
|
||||
assert.equal(
|
||||
out["omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id preserved"
|
||||
);
|
||||
assert.equal(out["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id preserved");
|
||||
});
|
||||
|
||||
test("provider hook: compression metadata fetcher NOT called by default (opt-in)", async () => {
|
||||
@@ -464,7 +459,7 @@ test("provider hook: compression metadata fetcher called when opted in", async (
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "compression metadata fetcher called");
|
||||
const combo = out["omniroute/claude-primary"];
|
||||
const combo = out["combo/claude-primary"];
|
||||
assert.ok(combo, "combo entry present");
|
||||
assert.match(
|
||||
combo.name,
|
||||
@@ -478,7 +473,7 @@ test("provider hook: compression metadata fetcher called when opted in", async (
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const stubAuthJson = (apiKey: string) => async () => ({
|
||||
"opencode-omniroute": { type: "api" as const, key: apiKey },
|
||||
omniroute: { type: "api" as const, key: apiKey },
|
||||
});
|
||||
|
||||
test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => {
|
||||
@@ -493,7 +488,7 @@ test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => {
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
assert.ok(input.provider?.["opencode-omniroute"], "provider block written");
|
||||
assert.ok(input.provider?.omniroute, "provider block written");
|
||||
assert.equal(input.mcp, undefined, "no mcp block written");
|
||||
});
|
||||
|
||||
@@ -513,7 +508,7 @@ test("config hook: features.mcpAutoEmit:true writes mcp entry with provider apiK
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.["opencode-omniroute"] as
|
||||
const entry = input.mcp?.omniroute as
|
||||
| { type: string; url: string; enabled: boolean; headers: Record<string, string> }
|
||||
| undefined;
|
||||
assert.ok(entry, "mcp entry written");
|
||||
@@ -543,7 +538,7 @@ test("config hook: features.mcpToken overrides provider apiKey in mcp Bearer", a
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.["opencode-omniroute"] as { headers: Record<string, string> };
|
||||
const entry = input.mcp?.omniroute as { headers: Record<string, string> };
|
||||
assert.equal(
|
||||
entry.headers.Authorization,
|
||||
"Bearer sk-mcp-narrower",
|
||||
@@ -566,11 +561,11 @@ test("config hook: existing operator mcp.<providerId> wins (no overwrite)", asyn
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {
|
||||
mcp: { "opencode-omniroute": { type: "custom-user-entry", url: "https://manual.example/mcp" } },
|
||||
mcp: { omniroute: { type: "custom-user-entry", url: "https://manual.example/mcp" } },
|
||||
};
|
||||
await hook(input as never);
|
||||
assert.deepEqual(
|
||||
input.mcp?.["opencode-omniroute"],
|
||||
input.mcp?.omniroute,
|
||||
{ type: "custom-user-entry", url: "https://manual.example/mcp" },
|
||||
"operator override preserved"
|
||||
);
|
||||
@@ -585,7 +580,7 @@ test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips corr
|
||||
},
|
||||
{
|
||||
readAuthJson: async () => ({
|
||||
"opencode-omniroute-preprod": { type: "api" as const, key: "sk-preprod" },
|
||||
"omniroute-preprod": { type: "api" as const, key: "sk-preprod" },
|
||||
}),
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
@@ -594,7 +589,7 @@ test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips corr
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.["opencode-omniroute-preprod"] as { url: string };
|
||||
const entry = input.mcp?.["omniroute-preprod"] as { url: string };
|
||||
assert.equal(
|
||||
entry.url,
|
||||
"https://or-preprod.example.com/api/mcp/stream",
|
||||
|
||||
@@ -212,8 +212,8 @@ test("shouldSanitizeForGemini: google-vertex/gemini-1.5-flash → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "google-vertex/gemini-1.5-flash" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: gemini/gemini-2.5-pro → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "gemini/gemini-2.5-pro" }), true);
|
||||
test("shouldSanitizeForGemini: gemini-cli/gemini-2.5-pro → true (real OmniRoute alias)", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "gemini-cli/gemini-2.5-pro" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: claude-sonnet-4 → false", () => {
|
||||
|
||||
@@ -38,8 +38,8 @@ test("multi-instance: two plugin invocations bind to their own providerId", asyn
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
assert.equal(a.auth?.provider, "opencode-omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "opencode-omniroute-preprod");
|
||||
assert.equal(a.auth?.provider, "omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "omniroute-preprod");
|
||||
});
|
||||
|
||||
test("multi-instance: hook objects + nested arrays are independent references", async () => {
|
||||
@@ -70,8 +70,8 @@ test("multi-instance: identical opts twice still yield independent objects", asy
|
||||
assert.notEqual(first.auth, second.auth);
|
||||
assert.notEqual(first.auth?.methods, second.auth?.methods);
|
||||
// Same provider id is fine — what matters is no shared mutable state.
|
||||
assert.equal(first.auth?.provider, "opencode-twin");
|
||||
assert.equal(second.auth?.provider, "opencode-twin");
|
||||
assert.equal(first.auth?.provider, "twin");
|
||||
assert.equal(second.auth?.provider, "twin");
|
||||
});
|
||||
|
||||
test("multi-instance: mutating instance A's auth.methods does not affect instance B", async () => {
|
||||
@@ -132,5 +132,5 @@ test("multi-instance: invalid opts on one instance does not poison the other", a
|
||||
providerId: "recovered",
|
||||
baseURL: "https://ok.example/v1",
|
||||
});
|
||||
assert.equal(ok.auth?.provider, "opencode-recovered");
|
||||
assert.equal(ok.auth?.provider, "recovered");
|
||||
});
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Regression test for #6859.
|
||||
*
|
||||
* `resolveOmniRoutePluginOptions()` auto-prefixes `providerId` with
|
||||
* `"opencode-"` (commit 75b52e286) so OpenCode 1.17.8+'s native-adapter gate
|
||||
* accepts it as an OC-registered provider id. That prefixed value must stay
|
||||
* OC-internal (AuthHook.provider / provider registration keys) — it must
|
||||
* NEVER leak into the identifiers OmniRoute's own server parses to resolve
|
||||
* credentials (`mapRawModelToModelV2`'s `id`/`providerID`,
|
||||
* `mapComboToModelV2`'s `providerID`, and the dynamic-hook catalog keys).
|
||||
*
|
||||
* OmniRoute's server-side `parseModel()` (open-sse/services/model.ts) splits
|
||||
* a dispatched model string on `/` to recover the provider name and look up
|
||||
* credentials. If the plugin embeds the OC-gate-prefixed id in that string,
|
||||
* the server looks up credentials for a provider named "opencode-omniroute"
|
||||
* (which never exists in `src/shared/constants/providers.ts`) instead of
|
||||
* "omniroute" — producing the exact "No credentials for opencode-omniroute" /
|
||||
* "No active credentials for provider: opencode-omniroute" errors reported
|
||||
* in #6859.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createOmniRouteProviderHook,
|
||||
mapRawModelToModelV2,
|
||||
resolveOmniRoutePluginOptions,
|
||||
} from "../src/index.js";
|
||||
|
||||
/**
|
||||
* Minimal stand-in for OmniRoute's own `parseModel()` (open-sse/services/
|
||||
* model.ts), which splits a dispatched `<providerID>/<modelID>` string on the
|
||||
* FIRST "/" to recover the provider name used for credential lookup. Kept
|
||||
* local (rather than cross-importing the real module) so this package's
|
||||
* self-contained test suite (`cd @omniroute/opencode-plugin && npm test`)
|
||||
* doesn't depend on the root repo's `@/*` path-alias resolution.
|
||||
*/
|
||||
function splitProviderFromDispatchedModel(modelStr: string): string {
|
||||
const idx = modelStr.indexOf("/");
|
||||
return idx === -1 ? modelStr : modelStr.slice(0, idx);
|
||||
}
|
||||
|
||||
const apiAuth = (key: string) => ({ type: "api" as const, key });
|
||||
|
||||
test("#6859: server-facing model id/providerID must resolve to the unprefixed provider name", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions();
|
||||
|
||||
// The OC-gate-compatible id stays prefixed — it is legitimate for
|
||||
// AuthHook.provider / provider registration.
|
||||
assert.equal(resolved.providerId, "opencode-omniroute");
|
||||
|
||||
// A second, unprefixed id must be exposed for anything that reaches
|
||||
// OmniRoute's own server (model id prefix, ModelV2.providerID, combo keys).
|
||||
assert.equal(
|
||||
resolved.omnirouteProviderId,
|
||||
"omniroute",
|
||||
"resolveOmniRoutePluginOptions() must expose an unprefixed omnirouteProviderId"
|
||||
);
|
||||
|
||||
// A bare raw /v1/models entry (no existing "/" in its id — the common
|
||||
// case for OmniRoute's catalog) mapped with the server-facing id.
|
||||
const model = mapRawModelToModelV2(
|
||||
{ id: "claude-opus-4-7" },
|
||||
{ providerId: resolved.omnirouteProviderId, baseURL: "http://localhost:20128" }
|
||||
);
|
||||
|
||||
assert.equal(model.providerID, "omniroute");
|
||||
assert.equal(model.id, "omniroute/claude-opus-4-7");
|
||||
|
||||
// OpenCode dispatches back to OmniRoute using `providerID/modelKey`
|
||||
// (matches the issue's own repro: `-m opencode-omniroute/oc/big-pickle`).
|
||||
const dispatchedModelString = `${model.providerID}/claude-opus-4-7`;
|
||||
const parsedProvider = splitProviderFromDispatchedModel(dispatchedModelString);
|
||||
|
||||
assert.equal(
|
||||
parsedProvider,
|
||||
"omniroute",
|
||||
`server-side provider split resolved '${parsedProvider}', expected 'omniroute' — ` +
|
||||
`credentials lookup would fail for an OC-gate-prefixed provider id`
|
||||
);
|
||||
});
|
||||
|
||||
test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID never carry the OC-gate prefix", async () => {
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{
|
||||
fetcher: async () => [{ id: "claude-opus-4-7" }],
|
||||
combosFetcher: async () => [],
|
||||
}
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-test") as never });
|
||||
const model = out["omniroute/claude-opus-4-7"];
|
||||
assert.ok(model, "catalog keyed under the unprefixed provider name");
|
||||
assert.equal(model.providerID, "omniroute");
|
||||
assert.ok(
|
||||
!model.providerID.startsWith("opencode-"),
|
||||
"the OC-gate prefix must never leak into ModelV2.providerID"
|
||||
);
|
||||
});
|
||||
@@ -75,7 +75,7 @@ const apiAuth = (key: string, baseURL?: string): unknown =>
|
||||
|
||||
test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] });
|
||||
assert.equal(hook.id, "opencode-omniroute");
|
||||
assert.equal(hook.id, "omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => {
|
||||
@@ -87,8 +87,8 @@ test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-ins
|
||||
{ providerId: "omniroute-local" },
|
||||
{ combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(a.id, "opencode-omniroute-preprod");
|
||||
assert.equal(b.id, "opencode-omniroute-local");
|
||||
assert.equal(a.id, "omniroute-preprod");
|
||||
assert.equal(b.id, "omniroute-local");
|
||||
});
|
||||
|
||||
test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => {
|
||||
@@ -101,10 +101,7 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
|
||||
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
|
||||
// that prefix must never leak into anything OmniRoute's server parses.
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
assert.ok(out["claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -155,15 +152,9 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
// #6859: dynamic-hook catalog keys/ids/providerID use the unprefixed
|
||||
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
|
||||
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
|
||||
// anything OmniRoute's own server parses for credential lookup.
|
||||
const claude = out["omniroute/claude-primary"];
|
||||
const claude = out["claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
||||
// static-catalog reader resolves `(providerID, modelID)` from the key.
|
||||
assert.equal(claude.id, "omniroute/claude-primary");
|
||||
assert.equal(claude.id, "claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
|
||||
@@ -26,7 +26,7 @@ test("scaffold: default export is v1 plugin shape { id, server: OmniRoutePlugin
|
||||
|
||||
test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
const r = resolveOmniRoutePluginOptions();
|
||||
assert.equal(r.providerId, "opencode-omniroute");
|
||||
assert.equal(r.providerId, "omniroute");
|
||||
assert.equal(r.displayName, "OmniRoute");
|
||||
assert.equal(r.modelCacheTtl, 300_000);
|
||||
assert.equal(r.baseURL, undefined);
|
||||
@@ -34,8 +34,8 @@ test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
|
||||
test("resolveOmniRoutePluginOptions: custom providerId derives displayName", () => {
|
||||
const r = resolveOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
|
||||
assert.equal(r.providerId, "opencode-omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (opencode-omniroute-preprod)");
|
||||
assert.equal(r.providerId, "omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (omniroute-preprod)");
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: explicit displayName wins", () => {
|
||||
|
||||
85
AGENTS.md
85
AGENTS.md
@@ -3,14 +3,14 @@
|
||||
## Project
|
||||
|
||||
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
|
||||
with **250 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
with **227 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
|
||||
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
|
||||
with **MCP Server** (94 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
with **MCP Server** (87 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
|
||||
> **Live counts (v3.8.47)**: providers 250 · MCP tools 94 · MCP scopes 30 · A2A skills 6 ·
|
||||
> open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 ·
|
||||
> DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 ·
|
||||
> **Live counts (v3.8.24)**: providers 227 · MCP tools 87 · MCP scopes 30 · A2A skills 6 ·
|
||||
> open-sse services 115 · routing strategies 15 · auto-combo scoring factors 9 ·
|
||||
> DB modules 83 · DB migrations 97 · base tables 17 · search providers 11 ·
|
||||
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
|
||||
|
||||
## Doc Accuracy Discipline (read before writing any doc)
|
||||
@@ -178,7 +178,7 @@ Always run `prettier --write` on changed files.
|
||||
|
||||
### Data Layer (`src/lib/db/`)
|
||||
|
||||
All persistence uses SQLite through **95 domain-specific modules** in `src/lib/db/`. Top modules:
|
||||
All persistence uses SQLite through **83 domain-specific modules** in `src/lib/db/`. Top modules:
|
||||
|
||||
- Core: `core.ts`, `migrationRunner.ts`, `encryption.ts`, `stateReset.ts`
|
||||
- Providers / catalog: `providers.ts`, `models.ts`, `providerLimits.ts`, `compressionAnalytics.ts`
|
||||
@@ -188,8 +188,8 @@ All persistence uses SQLite through **95 domain-specific modules** in `src/lib/d
|
||||
- Storage: `backup.ts`, `cleanup.ts`, `jsonMigration.ts`, `healthCheck.ts`, `databaseSettings.ts`
|
||||
- Extension modules: `evals.ts`, `webhooks.ts`, `reasoningCache.ts`, `readCache.ts`, `tierConfig.ts`, `compressionCombos.ts`, `compressionScheduler.ts`, `batches.ts`, `files.ts`, `syncTokens.ts`, `proxies.ts`, `oneproxy.ts`, `upstreamProxy.ts`, `versionManager.ts`, `cliToolState.ts`, `prompts.ts`, `detailedLogs.ts`, `contextHandoffs.ts`, `compression.ts`, `stats.ts`
|
||||
|
||||
Live count: `ls src/lib/db/*.ts | wc -l` (currently 95). Drift detection: `npm run check:docs-counts`.
|
||||
Schema migrations live in `db/migrations/` (**110 files** as of v3.8.43) and run via `migrationRunner.ts`.
|
||||
Live count: `ls src/lib/db/*.ts | wc -l` (currently 83). Drift detection: `npm run check:docs-counts`.
|
||||
Schema migrations live in `db/migrations/` (**97 files** as of v3.8.24) and run via `migrationRunner.ts`.
|
||||
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
|
||||
|
||||
#### DB Internals
|
||||
@@ -198,7 +198,7 @@ Schema migrations live in `db/migrations/` (**110 files** as of v3.8.43) and run
|
||||
journaling. `SCHEMA_SQL` defines **17 base tables** (verify with `grep -c "CREATE TABLE" src/lib/db/core.ts` minus 1 for the bookkeeping `_omniroute_migrations` table). Helpers: `rowToCamel`, `encryptConnectionFields`.
|
||||
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
|
||||
Tracks applied migrations in `_omniroute_migrations` table.
|
||||
- **Migrations**: 110 files (`001_initial_schema.sql` → `110_*.sql`).
|
||||
- **Migrations**: 97 files (`001_initial_schema.sql` → `099_*.sql`).
|
||||
Each migration is idempotent and runs in a transaction. Live count: `ls src/lib/db/migrations/*.sql | wc -l`.
|
||||
- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations.
|
||||
Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`,
|
||||
@@ -267,7 +267,7 @@ Zod schemas, and unit tests aligned when editing.
|
||||
|
||||
### Provider Categories
|
||||
|
||||
- **Free** (3): Qoder AI, Qwen Code, Kiro AI
|
||||
- **Free** (4): Qoder AI, Qwen Code, Gemini CLI (deprecated), Kiro AI
|
||||
- **OAuth** (14): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, Kimi Coding, Kilo Code, Cline, Qwen (⚠️ free tier discontinued 2026-04-15), Kiro, Qoder, Gemini, Windsurf (v3.8), GitLab Duo (v3.8)
|
||||
- **API Key** (120+): OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Perplexity,
|
||||
Together, Fireworks, Cerebras, Cohere, NVIDIA, Nebius, SiliconFlow, Hyperbolic,
|
||||
@@ -291,7 +291,7 @@ Providers are registered in `src/shared/constants/providers.ts` with Zod validat
|
||||
### Executors (`open-sse/executors/`)
|
||||
|
||||
Provider-specific request executors: `base.ts`, `default.ts`, `cursor.ts`, `codex.ts`,
|
||||
`antigravity.ts`, `github.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`,
|
||||
`antigravity.ts`, `github.ts`, `gemini-cli.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`,
|
||||
`cloudflare-ai.ts`, `opencode.ts`, `pollinations.ts`, `puter.ts`.
|
||||
|
||||
#### Executor Internals
|
||||
@@ -336,7 +336,7 @@ Includes request/response translators with helpers for image handling.
|
||||
|
||||
### Services (`open-sse/services/`)
|
||||
|
||||
134 service modules in `open-sse/services/` (top-level only; more including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
|
||||
115 service modules in `open-sse/services/` (top-level only; 184 including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
|
||||
`combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
|
||||
`rateLimitManager.ts`, `accountFallback.ts`, `sessionManager.ts`, `wildcardRouter.ts`,
|
||||
`autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`,
|
||||
@@ -378,8 +378,8 @@ Modular prompt compression that runs proactively before the existing reactive co
|
||||
and iterates through targets in order until one succeeds or all fail.
|
||||
- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of
|
||||
`ResolvedComboTarget[]`, each specifying provider + model + account + credentials.
|
||||
- **Strategies** (17): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
|
||||
reset-window, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay, headroom, fusion. Source: `ROUTING_STRATEGY_VALUES` in `src/shared/constants/routingStrategies.ts`.
|
||||
- **Strategies** (15): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
|
||||
reset-window, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay. Source: `ROUTING_STRATEGY_VALUES` in `src/shared/constants/routingStrategies.ts`.
|
||||
- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with
|
||||
per-target error handling and circuit breaker checks.
|
||||
|
||||
@@ -391,7 +391,7 @@ Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
|
||||
|
||||
### MCP Server (`open-sse/mcp-server/`)
|
||||
|
||||
**94 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 34-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), pool (6), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (30 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
|
||||
**87 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 33-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (30 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
|
||||
|
||||
**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota,
|
||||
route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard,
|
||||
@@ -489,7 +489,7 @@ Request middleware including `promptInjectionGuard.ts`.
|
||||
|
||||
### Guardrails (`src/lib/guardrails/`)
|
||||
|
||||
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open. The `pii-masker` guardrail is registered and runs on every request, but its data-mutating logic is **opt-in** and OFF by default — it only redacts when `PII_REDACTION_ENABLED` (request) / `PII_RESPONSE_SANITIZATION` (response + streaming) are enabled (both `defaultValue: "false"`); with them off, payloads pass through untouched. A request can additionally opt OUT of any guardrail via header (`x-omniroute-disabled-guardrails`). Never make PII default-on (Hard Rule #20). See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
|
||||
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open; per-request opt-out via header. See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
|
||||
|
||||
### Cloud Agents (`src/lib/cloudAgent/`)
|
||||
|
||||
@@ -534,33 +534,32 @@ Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md
|
||||
|
||||
For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
| Area | Doc |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
|
||||
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Auto-Combo (12-factor, 18 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
|
||||
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
|
||||
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
|
||||
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
|
||||
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
|
||||
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
|
||||
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
|
||||
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
|
||||
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/openapi.yaml`](docs/openapi.yaml) |
|
||||
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
|
||||
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
|
||||
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
|
||||
| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||
| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) |
|
||||
| Area | Doc |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
|
||||
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Auto-Combo (12-factor, 15 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
|
||||
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
|
||||
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
|
||||
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
|
||||
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
|
||||
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
|
||||
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
|
||||
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
|
||||
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/reference/openapi.yaml`](docs/reference/openapi.yaml) |
|
||||
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
|
||||
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
|
||||
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
|
||||
| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
2629
CHANGELOG.md
2629
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
151
CLAUDE.md
151
CLAUDE.md
@@ -35,22 +35,22 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 250 LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 227 LLM providers, auto-fallback.
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
|
||||
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
|
||||
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
|
||||
| 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 (95 files, 110 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 94 tools (34 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 30 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 |
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
|
||||
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
|
||||
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
|
||||
| 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 (83 files, 97 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 87 tools (33 base + memory/skill/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 30 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 |
|
||||
|
||||
Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point).
|
||||
|
||||
@@ -72,7 +72,7 @@ Client → /v1/chat/completions (Next.js route)
|
||||
|
||||
API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific.
|
||||
|
||||
**Combo routing** (`open-sse/services/combo.ts`): 18 strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 12-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||
**Combo routing** (`open-sse/services/combo.ts`): 15 strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, reset-aware, reset-window, strict-random, auto, lkgp, context-optimized, context-relay). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. See `docs/routing/AUTO-COMBO.md` for the 9-factor Auto-Combo scoring and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||
|
||||
---
|
||||
|
||||
@@ -221,7 +221,7 @@ connection continue serving other models.
|
||||
- **2 spaces**, semicolons, double quotes, 100 char width, es5 trailing commas (enforced by lint-staged via Prettier)
|
||||
- **Imports**: external → internal (`@/`, `@omniroute/open-sse`) → relative
|
||||
- **Naming**: files=camelCase/kebab, components=PascalCase, constants=UPPER_SNAKE
|
||||
- **ESLint**: `no-eval`, `no-implied-eval`, `no-new-func` = error everywhere; `no-explicit-any` = **error** in `open-sse/` and `tests/` (since #6218 — pre-existing violations are frozen in `config/quality/eslint-suppressions.json`, new ones must be fixed; `npm run lint` applies the suppressions and is what CI runs)
|
||||
- **ESLint**: `no-eval`, `no-implied-eval`, `no-new-func` = error everywhere; `no-explicit-any` = warn in `open-sse/` and `tests/`
|
||||
- **TypeScript**: `strict: false`, target ES2022, module esnext, resolution bundler. Prefer explicit types.
|
||||
|
||||
### Database
|
||||
@@ -311,7 +311,7 @@ connection continue serving other models.
|
||||
4. Create 7 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`.
|
||||
5. Verify `/api/services/` is in `LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`; add a test asserting `isLocalOnlyPath()` returns `true` for the new prefix if you add one (hard rule #17).
|
||||
6. Add a UI tab in `src/app/(dashboard)/dashboard/providers/services/tabs/` reusing `ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`.
|
||||
7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/openapi.yaml`.
|
||||
7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/reference/openapi.yaml`.
|
||||
8. Write tests: unit (`tests/unit/services/`), integration (`tests/integration/services/`, gated by `RUN_SERVICES_INT=1`), and update `docs/ops/RELEASE_CHECKLIST.md` smoke section.
|
||||
|
||||
### Adding a New Guardrail / Eval / Skill / Webhook event
|
||||
@@ -327,33 +327,33 @@ connection continue serving other models.
|
||||
|
||||
For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
| Area | Doc |
|
||||
| --------------------------------------------- | ------------------------------------------------------- |
|
||||
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
||||
| Architecture | `docs/architecture/ARCHITECTURE.md` |
|
||||
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
||||
| Auto-Combo (12-factor scoring, 18 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
|
||||
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
|
||||
| Skills framework | `docs/frameworks/SKILLS.md` |
|
||||
| Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` |
|
||||
| Cloud agents | `docs/frameworks/CLOUD_AGENT.md` |
|
||||
| Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` |
|
||||
| Public upstream credentials (Gemini/etc.) | `docs/security/PUBLIC_CREDS.md` |
|
||||
| Error message sanitization | `docs/security/ERROR_SANITIZATION.md` |
|
||||
| Evals | `docs/frameworks/EVALS.md` |
|
||||
| Compliance / audit | `docs/security/COMPLIANCE.md` |
|
||||
| Webhooks | `docs/frameworks/WEBHOOKS.md` |
|
||||
| Authorization pipeline | `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| Stealth (TLS / fingerprint) | `docs/security/STEALTH_GUIDE.md` |
|
||||
| Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` |
|
||||
| MCP server | `docs/frameworks/MCP-SERVER.md` |
|
||||
| A2A server | `docs/frameworks/A2A-SERVER.md` |
|
||||
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/openapi.yaml` |
|
||||
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
|
||||
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
|
||||
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
| Quality gates (~48 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
|
||||
| Area | Doc |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
||||
| Architecture | `docs/architecture/ARCHITECTURE.md` |
|
||||
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
||||
| Auto-Combo (9-factor scoring, 15 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
|
||||
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
|
||||
| Skills framework | `docs/frameworks/SKILLS.md` |
|
||||
| Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` |
|
||||
| Cloud agents | `docs/frameworks/CLOUD_AGENT.md` |
|
||||
| Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` |
|
||||
| Public upstream credentials (Gemini/etc.) | `docs/security/PUBLIC_CREDS.md` |
|
||||
| Error message sanitization | `docs/security/ERROR_SANITIZATION.md` |
|
||||
| Evals | `docs/frameworks/EVALS.md` |
|
||||
| Compliance / audit | `docs/security/COMPLIANCE.md` |
|
||||
| Webhooks | `docs/frameworks/WEBHOOKS.md` |
|
||||
| Authorization pipeline | `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| Stealth (TLS / fingerprint) | `docs/security/STEALTH_GUIDE.md` |
|
||||
| Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` |
|
||||
| MCP server | `docs/frameworks/MCP-SERVER.md` |
|
||||
| A2A server | `docs/frameworks/A2A-SERVER.md` |
|
||||
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/reference/openapi.yaml` |
|
||||
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
|
||||
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
|
||||
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
| Quality gates (~48 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
|
||||
|
||||
---
|
||||
|
||||
@@ -388,31 +388,6 @@ Why this matters: fixing bug A while opening bug B is worse than not fixing at a
|
||||
|
||||
---
|
||||
|
||||
## Planning & Research Artifacts (superpowers, deep-research)
|
||||
|
||||
`_tasks/` is a **separate, isolated git repository** that is gitignored by the main
|
||||
repo (`.gitignore` → `_tasks/`). It is the canonical home for working artifacts —
|
||||
plans, specs/designs, research, hand-offs — so they stay **versioned in their own
|
||||
repo** instead of polluting the main OmniRoute tree.
|
||||
|
||||
**Hard rule — never write superpowers / planning / research output under `docs/` or
|
||||
the repo root.** The superpowers skills ship with defaults that point at `docs/…`
|
||||
(`writing-plans` → `docs/superpowers/plans/`, `brainstorming` → `docs/superpowers/specs/`).
|
||||
Those defaults are **overridden here**. Whenever you invoke superpowers (or any
|
||||
plan/spec/research generator) in this project, save to `_tasks/` instead, using the
|
||||
same filename convention:
|
||||
|
||||
| Artifact (skill) | Default (do NOT use) | Save here instead |
|
||||
| ---------------------------------- | ------------------------- | ------------------------------------------------------------- |
|
||||
| Plans (`writing-plans`) | `docs/superpowers/plans/` | `_tasks/superpowers/plans/YYYY-MM-DD-<feature>.md` |
|
||||
| Specs / design (`brainstorming`) | `docs/superpowers/specs/` | `_tasks/superpowers/specs/YYYY-MM-DD-<topic>-design.md` |
|
||||
| Research (`deep-research`, ad-hoc) | `docs/research/` | `_tasks/research/…` |
|
||||
| Hand-offs (`/handoff`) | — | `_tasks/hands-off/<YYYY-MM-DD>_<branch>_v<versão>_sess-<id>/` |
|
||||
|
||||
When a superpowers skill announces a path like "saved to `docs/superpowers/plans/…`",
|
||||
rewrite it to the `_tasks/…` equivalent before writing. Commit those artifacts inside
|
||||
the `_tasks/` repo (`git -C _tasks …`), never in the main repo.
|
||||
|
||||
## Git Workflow
|
||||
|
||||
```bash
|
||||
@@ -428,10 +403,8 @@ git push -u origin feat/your-feature
|
||||
|
||||
**Husky hooks**:
|
||||
|
||||
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` + `check:tracked-artifacts`
|
||||
- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts`
|
||||
already run on pre-commit; re-running them on every push was pure double-pay. CI still
|
||||
enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.)
|
||||
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11`
|
||||
- **pre-push**: fast deterministic gates (`check:any-budget:t11` + `check:tracked-artifacts`); intentionally excludes `test:unit` (slow — covered by the CI `test-unit` job). Activated 2026-06-13 (Quality Gates Fase 6A.12).
|
||||
|
||||
### Worktree isolation (MANDATORY for every development task)
|
||||
|
||||
@@ -446,33 +419,25 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
|
||||
`AskUserQuestion`, unless they already told you) from which branch the new worktree/branch
|
||||
should be cut. Do NOT assume `main` or "whatever I'm on" — the answer is usually the active
|
||||
`release/vX.Y.Z`, but it can be another feature/release branch. Get the base explicitly.
|
||||
2. **Create an isolated worktree + branch off that base** (never reuse the main checkout).
|
||||
**🔴 MANDATORY PATH: every worktree lives under `.claude/worktrees/` — and nowhere else.**
|
||||
This is the single canonical location (the same dir the native `EnterWorktree` tool uses). It
|
||||
is gitignored AND in the `tsconfig.json` / `.dockerignore` excludes, so worktrees never leak
|
||||
into the build scope. **Never** use `.worktrees/`, repo-root, or any other path — a worktree
|
||||
outside `.claude/worktrees/` (a) escapes the build-scope excludes and poisons `next build` (the
|
||||
`tsconfig` `include: **/*` globs ~70× the codebase → OOM; incident 2026-06-25) and (b) scatters
|
||||
worktrees across two dirs.
|
||||
2. **Create an isolated worktree + branch off that base** (never reuse the main checkout):
|
||||
|
||||
```bash
|
||||
BASE_BRANCH="release/vX.Y.Z" # ← the branch the operator confirmed in step 1
|
||||
TASK="feat/your-feature" # feat/ fix/ refactor/ docs/ test/ chore/
|
||||
git fetch origin "$BASE_BRANCH"
|
||||
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
||||
cd ".claude/worktrees/${TASK##*/}"
|
||||
git worktree add ".worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
|
||||
cd ".worktrees/${TASK##*/}"
|
||||
# symlink node_modules from the main checkout to skip a per-worktree npm install:
|
||||
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||
```
|
||||
|
||||
In Claude Code prefer the native `EnterWorktree` tool (it already creates worktrees under
|
||||
`.claude/worktrees/`): create the worktree with the command above, then call `EnterWorktree`
|
||||
with its `path`.
|
||||
In Claude Code prefer the native `EnterWorktree` tool (create the worktree with the command
|
||||
above, then call `EnterWorktree` with its `path`).
|
||||
|
||||
3. **Work, commit, push, open the PR — all from inside the worktree.** Never `git checkout` a
|
||||
different branch inside a worktree another session might share.
|
||||
4. **Tear down only your own** worktree + branch when done, from the main checkout:
|
||||
`git worktree remove .claude/worktrees/<dir>` then `git branch -D <task>`. Never blanket-delete
|
||||
`git worktree remove .worktrees/<dir>` then `git branch -D <task>`. Never blanket-delete
|
||||
`fix/*`/`feat/*` — other sessions keep their own; delete only the branches you created, by name.
|
||||
5. **Never touch another session's worktree, branch, or uncommitted changes.** If `git worktree
|
||||
list` shows worktrees you didn't create, leave them alone. End every session with the main
|
||||
@@ -482,8 +447,7 @@ list` shows worktrees you didn't create, leave them alone. End every session wit
|
||||
|
||||
## Environment
|
||||
|
||||
- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules. This is the **only** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun.
|
||||
- **Bun (build/dev script runner only)**: Bun `1.3.10` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression`. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the published runtime, or the test runners — those stay on Node. Any new Bun-invoking script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those 5 scripts with `bun: not found`).
|
||||
- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules
|
||||
- **TypeScript**: 6.0+, target ES2022, module esnext, resolution bundler
|
||||
- **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*`
|
||||
- **Default port**: 20128 (API + dashboard on same port)
|
||||
@@ -537,15 +501,10 @@ the stale-enforcement added in Fase 6A.3.
|
||||
13. Never string-interpolate external paths or runtime values into shell scripts passed to `exec()`/`spawn()` — pass via the `env` option instead. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`.
|
||||
15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
16. Never credit or advertise an AI assistant, LLM, or automation account in any commit/PR metadata. Two forbidden forms, both equivalent — they route attribution to a bot account (or advertise AI authorship) and hide the real author (`diegosouzapw`): **(a)** `Co-Authored-By` trailers naming an AI/bot (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses); **(b)** AI-generation footers or descriptions anywhere in a commit message, PR title/body, or CHANGELOG — e.g. `🤖 Generated with [Claude Code]`, "Generated with Claude Code", "Made with <AI tool>", or any `Co-authored-by: Claude/GPT/Copilot` line. This **overrides any harness, template, or tool default that auto-appends such a footer** (e.g. the Claude Code PR-body/commit default) — strip it before pushing; do not let it reach a commit, PR, or CHANGELOG. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
|
||||
16. Never include `Co-Authored-By` trailers that credit an AI assistant, LLM, or automation account (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses). Such trailers route attribution to the bot account on GitHub, hiding the real author (`diegosouzapw`) in PR history. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
|
||||
17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree.
|
||||
19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation".
|
||||
20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`.
|
||||
21. **Release-freeze — the FROZEN release branch belongs to the release captain; development does NOT stop (parallel-cycle model, 2026-07-04).** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a), **immediately cuts the next cycle's branch `release/vX+1` from the frozen tip (Phase 0a.0b — bump + living release PR + re-home of open PRs)**, and closes the freeze once the release PR squash-merges to `main`. Before merging **any** PR, every campaign workflow (`/review-prs`, `/review-group-prs`, `/merge-prs`, `/triage-fix-bugs`, `/implement-fix-bugs`, `/triage-features`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active: **NEVER merge into the frozen `release/vX.Y.Z` named in the freeze title**; instead resolve the ACTIVE development branch (the **highest** `release/v*` by semver — normally `release/vX+1`, announced in a freeze-issue comment) and **retarget the PR there** (`gh pr edit <N> --base release/vX+1`, then VERIFY with `gh pr view <N> --json baseRefName` — the edit fails silently) and merge normally. **HOLD only when the highest release/v\* branch IS the frozen one** (the short window before 0a.0b completes, or a pre-parallel-cycle release) — in that case leave the PR ready and open, tell the operator, and resume when the next branch appears or the freeze lifts. The just-shipped fixes reach `release/vX+1` via the Phase 5 sync-back (`scripts/release/sync-next-cycle.mjs`); do not try to sync mid-release. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view <N> --json state`) is the authorized captain freeze — hold, don't touch.
|
||||
22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening):
|
||||
- **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show <ref>:<path>` or `git diff <ref> -- <path>`; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:<path>`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent).
|
||||
- **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create _this_ session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view <N> --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -160,31 +160,19 @@ npm run test:protocols:e2e
|
||||
# Ecosystem compatibility tests
|
||||
npm run test:ecosystem
|
||||
|
||||
# Coverage gate: 60% statements/lines/functions/branches
|
||||
# Coverage gate: 75% statements/lines/functions, 70% branches
|
||||
npm run test:coverage
|
||||
npm run coverage:report
|
||||
|
||||
# Lint + format check
|
||||
npm run lint
|
||||
npm run check
|
||||
|
||||
# Gated real-upstream combo smoke (requires VPS access + real provider credits)
|
||||
# Hits REAL providers — costs a little. NEVER runs in CI. Skips cleanly without the gate.
|
||||
# Needs: ssh root@192.168.0.15 access (sources a read-only DB snapshot from the VPS).
|
||||
RUN_COMBO_LIVE=1 npm run test:combo:live
|
||||
|
||||
# Phase-3 VPS live smoke — plain Node ESM scripts, hit the live .15 server directly.
|
||||
# Requires: ssh root@192.168.0.15 access (combos created/torn down via SSH sqlite).
|
||||
# Hits REAL providers (small cost). Creates/deletes only __live_test__* combos. NEVER runs in CI.
|
||||
# REQUIRE_API_KEY=false on .15 so no API key needed, but honors COMBO_LIVE_BASE_URL / COMBO_LIVE_API_KEY if set.
|
||||
npm run test:combo:live:vps # 7 HTTP scenarios (priority/round-robin/weighted/cost/fusion/auto + health)
|
||||
npm run test:combo:live:vps:failover # adds a real cross-provider failover scenario (8 total)
|
||||
```
|
||||
|
||||
Coverage notes:
|
||||
|
||||
- `npm run test:coverage` measures source coverage for the main unit test suite, excludes `tests/**`, and includes `open-sse/**`
|
||||
- Pull requests must keep the coverage gate at **60%+** statements/lines/functions/branches
|
||||
- Pull requests must keep the coverage gate at **75%+** statements/lines/functions and **70%+** branches
|
||||
- If a PR changes production code in `src/`, `open-sse/`, `electron/`, or `bin/`, it must add or update automated tests in the same PR
|
||||
- `npm run coverage:report` prints the detailed file-by-file report from the latest coverage run
|
||||
- `npm run test:coverage:legacy` preserves the older metric for historical comparison
|
||||
@@ -196,7 +184,7 @@ Before opening or merging a PR:
|
||||
|
||||
- Run `npm run test:unit`
|
||||
- Run `npm run test:coverage`
|
||||
- Ensure the coverage gate stays at **60%+** statements/lines/functions/branches
|
||||
- Ensure the coverage gate stays at **75%+** statements/lines/functions, **70%+** branches
|
||||
- Include the changed or added test files in the PR description when production code changed
|
||||
- Check the SonarQube result on the PR when the project secrets are configured in CI
|
||||
|
||||
@@ -341,7 +329,7 @@ Write unit tests in `tests/unit/` covering at minimum:
|
||||
- [ ] Error responses route through `buildErrorBody()` / `sanitizeErrorMessage()` — no raw stack traces in response bodies (see [`docs/security/ERROR_SANITIZATION.md`](./docs/security/ERROR_SANITIZATION.md))
|
||||
- [ ] Shell commands (`exec` / `spawn`) pass runtime values via `env`, not via string interpolation
|
||||
- [ ] All inputs validated with Zod schemas
|
||||
- [ ] Changelog **fragment** added under `changelog.d/{features|fixes|maintenance}/<PR>-<slug>.md` for user-facing changes (see [`changelog.d/README.md`](./changelog.d/README.md)) — do **not** edit `CHANGELOG.md` directly; fragments are aggregated at release time and never conflict between PRs
|
||||
- [ ] CHANGELOG updated (if user-facing change)
|
||||
- [ ] Documentation updated (if applicable)
|
||||
- [ ] No new CodeQL / Secret-Scanning alerts opened, or each one dismissed with technical justification referencing the relevant `docs/security/` doc
|
||||
- [ ] Routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) classified as `isLocalOnlyPath()` in `src/server/authz/routeGuard.ts` — see [Hard Rule #15](docs/security/ROUTE_GUARD_TIERS.md)
|
||||
|
||||
97
Dockerfile
97
Dockerfile
@@ -2,46 +2,24 @@
|
||||
FROM node:24-trixie-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
# `apt-get upgrade` pulls the security-patched versions of the Debian (trixie)
|
||||
# base-image packages at build time — clears the subset of container-scan CVEs
|
||||
# (perl / util-linux / systemd / ncurses / zlib / tar / sqlite / shadow / pam …)
|
||||
# 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=apt-cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
|
||||
apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Refresh the globally-installed npm so its *bundled* node_modules (undici, tar)
|
||||
# ship the patched versions. These are npm's own internals — not application
|
||||
# dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) —
|
||||
# but the container scanner flags the stale copies under
|
||||
# /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in
|
||||
# the runner stages, so this is hygiene, not an exploitable runtime path.
|
||||
RUN npm install -g npm@latest \
|
||||
&& npm cache clean --force
|
||||
|
||||
# ── Builder ────────────────────────────────────────────────────────────────
|
||||
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=apt-cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
# Workspace package manifests MUST be present before `npm ci` so npm materializes
|
||||
# the workspace and installs its *workspace-only* deps (e.g. safe-regex,
|
||||
# @toon-format/toon — declared in open-sse/package.json, not hoisted to root).
|
||||
# Without this, `npm ci` skips them and the application build fails with "Module not
|
||||
# found" (root cause of the v3.8.39 Docker build break). workspaces = ["open-sse"].
|
||||
COPY open-sse/package.json ./open-sse/package.json
|
||||
COPY scripts/build/postinstall.mjs ./scripts/build/postinstall.mjs
|
||||
COPY scripts/build/postinstallSupport.mjs ./scripts/build/postinstallSupport.mjs
|
||||
COPY scripts/build/native-binary-compat.mjs ./scripts/build/native-binary-compat.mjs
|
||||
@@ -55,51 +33,32 @@ ENV NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||
# are reproducible.
|
||||
RUN test -f package-lock.json \
|
||||
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
|
||||
# `npm rebuild <pkg>` re-runs the package's own install script, so under npm 11 +
|
||||
# `--ignore-scripts` on the parent `npm ci` it depends on npm's script-allowlist
|
||||
# machinery correctly re-enabling that one package's script. Some self-hosted build
|
||||
# environments (e.g. Dokploy) hit a broken/incomplete better-sqlite3 native binding
|
||||
# from that indirection. Invoking `node-gyp rebuild` directly inside the package
|
||||
# directory bypasses npm's script-running layer entirely and is deterministic
|
||||
# regardless of npm version or ignore-scripts allowlist behavior.
|
||||
# node-gyp comes from npm's own bundled copy (deterministic, already in the image)
|
||||
# instead of `npx --yes`, which would install an arbitrary registry version
|
||||
# on-demand and run its lifecycle scripts (Sonar docker:S6505).
|
||||
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --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) \
|
||||
&& npm rebuild better-sqlite3 \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era
|
||||
# TurbopackInternalError panic ("entered unreachable code: there must be a path to a
|
||||
# root" in ImportTracer::get_traces) no longer reproduces on Next 16.2.9 — validated
|
||||
# 2026-07-05 with clean amd64 (12min14s, image smoke-tested: /api/monitoring/health
|
||||
# 200) and arm64 (qemu, exit 0, zero panic strings) builds. Turbopack cut the bare
|
||||
# build from 17min to 9min on the same 32-core box. Webpack stays available as the
|
||||
# escape hatch: `--build-arg`/-e OMNIROUTE_USE_TURBOPACK=0.
|
||||
# Build with webpack (stable). Turbopack hit a non-recoverable internal panic on this
|
||||
# Next.js version during the v3.8.27 release build — TurbopackInternalError "entered
|
||||
# unreachable code: there must be a path to a root" in ImportTracer::get_traces, on both
|
||||
# linux/amd64 and linux/arm64. Webpack is the proven engine (build:release / VPS / CI Build
|
||||
# all green). Re-enable Turbopack (=1) once the upstream tracer bug is fixed.
|
||||
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
|
||||
ENV OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
# 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).
|
||||
ENV OMNIROUTE_MITM_STUB=1
|
||||
ENV OMNIROUTE_USE_TURBOPACK=0
|
||||
|
||||
# Raise the V8 heap ceiling for the build. The webpack production optimization
|
||||
# pass needs more than V8's default ceiling (~2 GB) for a codebase this size; a
|
||||
# memory-constrained Docker build otherwise dies with "FATAL ERROR: ... JavaScript
|
||||
# heap out of memory" during the builder stage (#4076). Turbopack's compile is
|
||||
# native (Rust) and less V8-heap-bound, but the prerender/export phase still runs
|
||||
# on V8, so keep the ceiling. NODE_OPTIONS propagates to the spawned `next build`
|
||||
# child (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env).
|
||||
# Build-only; the runtime heap is set separately on the runner stage
|
||||
# (OMNIROUTE_MEMORY_MB). Override: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
|
||||
# pass (forced above since Turbopack panics) needs more than V8's default ceiling
|
||||
# (~2 GB) for a codebase this size; a memory-constrained Docker build otherwise
|
||||
# dies with "FATAL ERROR: ... JavaScript heap out of memory" at `[builder] npm run
|
||||
# build` (#4076). NODE_OPTIONS propagates to the spawned `next build` child
|
||||
# (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env). Build-only;
|
||||
# the runtime heap is set separately on the runner stage (OMNIROUTE_MEMORY_MB).
|
||||
# Override for hosts with more/less RAM: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \
|
||||
RUN --mount=type=cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data && npm run build
|
||||
|
||||
# ── Runner base ────────────────────────────────────────────────────────────
|
||||
@@ -114,12 +73,6 @@ LABEL org.opencontainers.image.title="omniroute" \
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=20128
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
# Runtime heap ceiling. 1024MB is enough for normal traffic but can be tight
|
||||
# for large fusion-combo panels (many models fanned out in parallel, each
|
||||
# response buffered in full — see open-sse/services/fusion.ts::FUSION_DEFAULTS
|
||||
# .maxPanel, issue #1905). Override at `docker run` time with
|
||||
# `-e OMNIROUTE_MEMORY_MB=2048` (or higher) if you raise fusionTuning.maxPanel
|
||||
# above the default cap.
|
||||
ENV OMNIROUTE_MEMORY_MB=1024
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"
|
||||
|
||||
@@ -201,8 +154,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=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,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 \
|
||||
@@ -218,15 +171,15 @@ FROM runner-base AS runner-cli
|
||||
USER root
|
||||
|
||||
# Install system dependencies required by openclaw (git+ssh references).
|
||||
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 \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,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=npm-cache,target=/root/.npm \
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
|
||||
|
||||
USER node
|
||||
|
||||
639
README.md
639
README.md
@@ -6,7 +6,7 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
### Never stop coding. Connect every AI tool to **250 providers** — **90+ free** — through one endpoint.
|
||||
### Never stop coding. Connect every AI tool to **227 providers** — **50+ free** — through one endpoint.
|
||||
|
||||
**Plug Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini. Auto-fallback.**
|
||||
<br/>
|
||||
@@ -15,27 +15,15 @@
|
||||
|
||||
<br/>
|
||||
|
||||
**~1.6B documented free tokens/month** — up to **~2.1B in your first month** with signup credits — aggregated across the free tiers, plus a long tail of permanently-free, no-cap providers, and the compression above stretches every one further. ([how we count →](docs/reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate))
|
||||
**~1.5B documented free tokens/month** — up to **~2.1B in your first month** with signup credits — aggregated across the free tiers, plus a long tail of permanently-free, no-cap providers, and the compression above stretches every one further. ([how we count →](docs/reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate))
|
||||
|
||||
<br/>
|
||||
|
||||
<h3>
|
||||
|
||||
⭐ Star the repo if OMNIROUTE helped you save money and make your work easier.
|
||||
|
||||
</h3>
|
||||
|
||||
[](https://github.com/diegosouzapw/OmniRoute)
|
||||
<a href="https://trendshift.io/repositories/23589" target="_blank"><img src="https://trendshift.io/api/badge/repositories/23589" alt="diegosouzapw%2FOmniRoute | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
[](https://www.star-history.com/diegosouzapw/omniroute)
|
||||
|
||||
</br>
|
||||
|
||||
[](#-250-ai-providers--90-free)
|
||||
[](#-250-ai-providers--90-free)
|
||||
[](docs/reference/FREE_TIERS.md)
|
||||
[](#-227-ai-providers--50-free)
|
||||
[](#-227-ai-providers--50-free)
|
||||
[](docs/reference/FREE_TIERS.md)
|
||||
[](#%EF%B8%8F-save-1595-tokens--automatically)
|
||||
[](#-combos--the-flagship)
|
||||
[](#-combos--the-flagship)
|
||||
[](#-quick-start)
|
||||
|
||||
<br/>
|
||||
@@ -46,78 +34,78 @@
|
||||
[](https://t.me/omnirouteOficial)
|
||||
[](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
|
||||
[](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)
|
||||
[](https://omniroute.online)
|
||||
|
||||
**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/EkzRkpzKYt) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 Brasil](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)**
|
||||
|
||||
<br/>
|
||||
|
||||
### 🧩 Available
|
||||
<a href="https://trendshift.io/repositories/23589" target="_blank"><img src="https://trendshift.io/api/badge/repositories/23589" alt="diegosouzapw%2FOmniRoute | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||
[](LICENSE)
|
||||
[](package.json)
|
||||
[](https://github.com/diegosouzapw/OmniRoute)
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.npmjs.com/package/omniroute)
|
||||

|
||||
[](https://hub.docker.com/r/diegosouzapw/omniroute)
|
||||
[](LICENSE)
|
||||

|
||||

|
||||
[](https://omniroute.online)
|
||||
|
||||
[**🚀 Quick Start**](#-quick-start) • [**🎯 Combos**](#-combos--the-flagship) • [**🌐 Providers**](#-250-ai-providers--90-free) • [**🔌 CLI & MCP**](#-full-cli--a2a--mcp) • [**🗜️ Compression**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 Website**](https://omniroute.online)
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
[**🚀 Quick Start**](#-quick-start) • [**🎯 Combos**](#-combos--the-flagship) • [**🌐 Providers**](#-227-ai-providers--50-free) • [**🔌 CLI & MCP**](#-full-cli--a2a--mcp) • [**🗜️ Compression**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 Website**](https://omniroute.online)
|
||||
|
||||
[💥 The Promise](#-the-promise) • [🤔 Why](#-why-omniroute) • [🏆 What Sets Apart](#-what-sets-omniroute-apart) • [🤖 Compatible CLIs](#-compatible-clis--coding-agents) • [🖥️ Where It Runs](#%EF%B8%8F-where-omniroute-runs--anywhere) • [🔒 Private](#-private--local-first) • [🎬 In Action](#-omniroute-in-action) • [📚 Explore More](#-explore-more) • [📧 Support](#-support--community)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<b>🌐 In 42+ languages</b>
|
||||
<table>
|
||||
<b>🌐 Available in 40+ languages</b>
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="README.md"><img src="docs/assets/flags/us.svg" width="26" alt="English (en)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/pt-BR/README.md"><img src="docs/assets/flags/br.svg" width="26" alt="Português — Brasil (pt-BR)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/pt/README.md"><img src="docs/assets/flags/pt.svg" width="26" alt="Português (pt)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/es/README.md"><img src="docs/assets/flags/es.svg" width="26" alt="Español (es)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/fr/README.md"><img src="docs/assets/flags/fr.svg" width="26" alt="Français (fr)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/it/README.md"><img src="docs/assets/flags/it.svg" width="26" alt="Italiano (it)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/de/README.md"><img src="docs/assets/flags/de.svg" width="26" alt="Deutsch (de)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/nl/README.md"><img src="docs/assets/flags/nl.svg" width="26" alt="Nederlands (nl)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/ru/README.md"><img src="docs/assets/flags/ru.svg" width="26" alt="Русский (ru)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/uk-UA/README.md"><img src="docs/assets/flags/ua.svg" width="26" alt="Українська (uk-UA)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/pl/README.md"><img src="docs/assets/flags/pl.svg" width="26" alt="Polski (pl)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/cs/README.md"><img src="docs/assets/flags/cz.svg" width="26" alt="Čeština (cs)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/sk/README.md"><img src="docs/assets/flags/sk.svg" width="26" alt="Slovenčina (sk)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/ro/README.md"><img src="docs/assets/flags/ro.svg" width="26" alt="Română (ro)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/hu/README.md"><img src="docs/assets/flags/hu.svg" width="26" alt="Magyar (hu)"></a></td>
|
||||
<td align="center"><a href="README.md">🇺🇸</a></td>
|
||||
<td align="center"><a href="docs/i18n/pt-BR/README.md">🇧🇷</a></td>
|
||||
<td align="center"><a href="docs/i18n/es/README.md">🇪🇸</a></td>
|
||||
<td align="center"><a href="docs/i18n/fr/README.md">🇫🇷</a></td>
|
||||
<td align="center"><a href="docs/i18n/it/README.md">🇮🇹</a></td>
|
||||
<td align="center"><a href="docs/i18n/ru/README.md">🇷🇺</a></td>
|
||||
<td align="center"><a href="docs/i18n/zh-CN/README.md">🇨🇳</a></td>
|
||||
<td align="center"><a href="docs/i18n/de/README.md">🇩🇪</a></td>
|
||||
<td align="center"><a href="docs/i18n/ja/README.md">🇯🇵</a></td>
|
||||
<td align="center"><a href="docs/i18n/ko/README.md">🇰🇷</a></td>
|
||||
<td align="center"><a href="docs/i18n/in/README.md">🇮🇳</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="docs/i18n/bg/README.md"><img src="docs/assets/flags/bg.svg" width="26" alt="Български (bg)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/da/README.md"><img src="docs/assets/flags/dk.svg" width="26" alt="Dansk (da)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/fi/README.md"><img src="docs/assets/flags/fi.svg" width="26" alt="Suomi (fi)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/no/README.md"><img src="docs/assets/flags/no.svg" width="26" alt="Norsk (no)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/sv/README.md"><img src="docs/assets/flags/se.svg" width="26" alt="Svenska (sv)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/zh-CN/README.md"><img src="docs/assets/flags/cn.svg" width="26" alt="中文 — 简体 (zh-CN)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/zh-TW/README.md"><img src="docs/assets/flags/tw.svg" width="26" alt="中文 — 繁體 (zh-TW)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/ja/README.md"><img src="docs/assets/flags/jp.svg" width="26" alt="日本語 (ja)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/ko/README.md"><img src="docs/assets/flags/kr.svg" width="26" alt="한국어 (ko)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/th/README.md"><img src="docs/assets/flags/th.svg" width="26" alt="ไทย (th)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/vi/README.md"><img src="docs/assets/flags/vn.svg" width="26" alt="Tiếng Việt (vi)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/id/README.md"><img src="docs/assets/flags/id.svg" width="26" alt="Bahasa Indonesia (id)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/ms/README.md"><img src="docs/assets/flags/my.svg" width="26" alt="Bahasa Melayu (ms)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/phi/README.md"><img src="docs/assets/flags/ph.svg" width="26" alt="Filipino (phi)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/th/README.md">🇹🇭</a></td>
|
||||
<td align="center"><a href="docs/i18n/vi/README.md">🇻🇳</a></td>
|
||||
<td align="center"><a href="docs/i18n/id/README.md">🇮🇩</a></td>
|
||||
<td align="center"><a href="docs/i18n/ms/README.md">🇲🇾</a></td>
|
||||
<td align="center"><a href="docs/i18n/phi/README.md">🇵🇭</a></td>
|
||||
<td align="center"><a href="docs/i18n/ar/README.md">🇸🇦</a></td>
|
||||
<td align="center"><a href="docs/i18n/he/README.md">🇮🇱</a></td>
|
||||
<td align="center"><a href="docs/i18n/az/README.md">🇦🇿</a></td>
|
||||
<td align="center"><a href="docs/i18n/uk-UA/README.md">🇺🇦</a></td>
|
||||
<td align="center"><a href="docs/i18n/pl/README.md">🇵🇱</a></td>
|
||||
<td align="center"><a href="docs/i18n/cs/README.md">🇨🇿</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="docs/i18n/in/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="हिन्दी (in)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/hi/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="हिन्दी (hi)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/gu/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="ગુજરાતી (gu)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/mr/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="मराठी (mr)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/ta/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="தமிழ் (ta)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/te/README.md"><img src="docs/assets/flags/in.svg" width="26" alt="తెలుగు (te)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/bn/README.md"><img src="docs/assets/flags/bd.svg" width="26" alt="বাংলা (bn)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/ur/README.md"><img src="docs/assets/flags/pk.svg" width="26" alt="اردو (ur)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/fa/README.md"><img src="docs/assets/flags/ir.svg" width="26" alt="فارسی (fa)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/ar/README.md"><img src="docs/assets/flags/sa.svg" width="26" alt="العربية (ar)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/he/README.md"><img src="docs/assets/flags/il.svg" width="26" alt="עברית (he)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/tr/README.md"><img src="docs/assets/flags/tr.svg" width="26" alt="Türkçe (tr)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/az/README.md"><img src="docs/assets/flags/az.svg" width="26" alt="Azərbaycan (az)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/sw/README.md"><img src="docs/assets/flags/tz.svg" width="26" alt="Kiswahili (sw)"></a></td>
|
||||
<td align="center"><a href="docs/i18n/nl/README.md">🇳🇱</a></td>
|
||||
<td align="center"><a href="docs/i18n/bg/README.md">🇧🇬</a></td>
|
||||
<td align="center"><a href="docs/i18n/da/README.md">🇩🇰</a></td>
|
||||
<td align="center"><a href="docs/i18n/fi/README.md">🇫🇮</a></td>
|
||||
<td align="center"><a href="docs/i18n/no/README.md">🇳🇴</a></td>
|
||||
<td align="center"><a href="docs/i18n/sv/README.md">🇸🇪</a></td>
|
||||
<td align="center"><a href="docs/i18n/hu/README.md">🇭🇺</a></td>
|
||||
<td align="center"><a href="docs/i18n/ro/README.md">🇷🇴</a></td>
|
||||
<td align="center"><a href="docs/i18n/sk/README.md">🇸🇰</a></td>
|
||||
<td align="center"><a href="docs/i18n/pt/README.md">🇵🇹</a></td>
|
||||
<td align="center"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -126,13 +114,13 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 💰 ~1.6B Free Tokens / Month
|
||||
# 💰 ~1.5B Free Tokens / Month
|
||||
|
||||
</div>
|
||||
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute aggregates the **documented** free tiers of **40+ provider pools / 500+ models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`).
|
||||
|
||||
- **~1.6B free tokens / month** (steady) — and **up to ~2.1B in your first month** with signup credits.
|
||||
- **~1.5B free tokens / month** (steady) — and **up to ~2.1B in your first month** with signup credits.
|
||||
- **Pool-deduped, honest** — we count each shared free pool **once**, so the headline isn't inflated by rate-limit ceilings the way multi-billion competitor claims are. (Counting every rate limit 24/7 would read ~10B; we don't publish that.)
|
||||
- **Plus the un-countable** — permanently-free, no-token-cap providers (SiliconFlow, Z.AI GLM-Flash, Kilo, OpenCode Zen…) and a **$10 OpenRouter top-up** that unlocks **+24M/mo**, both surfaced separately so they never inflate the headline.
|
||||
- **Per-model breakdown**, **live used / remaining** for the current month, and a transparent **terms flag** per provider.
|
||||
@@ -149,18 +137,18 @@
|
||||
|
||||
</div>
|
||||
|
||||
> One endpoint. **250 providers.** Never stop building — and let OmniRoute pick the cheapest one that works.
|
||||
> One endpoint. **227 providers.** Never stop building — and let OmniRoute pick the cheapest one that works.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><b>🚫 Never hit limits</b><br/><sub>Auto-fallback across 250 providers in milliseconds. Quota out? Next provider takes over — zero downtime.</sub></td>
|
||||
<td width="33%" valign="top"><b>🚫 Never hit limits</b><br/><sub>Auto-fallback across 227 providers in milliseconds. Quota out? Next provider takes over — zero downtime.</sub></td>
|
||||
<td width="33%" valign="top"><b>💸 Save up to 95% tokens</b><br/><sub>RTK + Caveman stacked compression cuts 15–95% of eligible tokens (~89% avg on tool-heavy sessions).</sub></td>
|
||||
<td width="33%" valign="top"><b>🆓 $0 to start</b><br/><sub>90+ providers with a free tier, 11 free <i>forever</i> (Kiro, Qoder, Pollinations, LongCat…). No card needed.</sub></td>
|
||||
<td width="33%" valign="top"><b>🆓 $0 to start</b><br/><sub>50+ providers with a free tier, 11 free <i>forever</i> (Kiro, Qoder, Pollinations, LongCat…). No card needed.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><b>🔌 Every tool works</b><br/><sub>24+ coding agents — Claude Code, Codex, Cursor, Cline, Copilot, Antigravity — through one config.</sub></td>
|
||||
<td width="33%" valign="top"><b>🔌 Every tool works</b><br/><sub>16+ coding agents — Claude Code, Codex, Cursor, Cline, Copilot, Antigravity — through one config.</sub></td>
|
||||
<td width="33%" valign="top"><b>🧩 One endpoint</b><br/><sub>OpenAI ↔ Claude ↔ Gemini ↔ Responses API translation. Point any tool at <code>/v1</code> and it just works.</sub></td>
|
||||
<td width="33%" valign="top"><b>🛡️ Production-grade</b><br/><sub>Circuit breakers, TLS stealth, MCP (94 tools), A2A, memory, guardrails, evals. 21,000+ tests.</sub></td>
|
||||
<td width="33%" valign="top"><b>🛡️ Production-grade</b><br/><sub>Circuit breakers, TLS stealth, MCP (87 tools), A2A, memory, guardrails, evals. 14,965 tests.</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -194,7 +182,7 @@
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ OmniRoute — Smart Router │
|
||||
│ RTK + Caveman compression · 18 routing strategies │
|
||||
│ RTK + Caveman compression · 15 routing strategies │
|
||||
│ Circuit breakers · TLS stealth · MCP · A2A · Guardrails │
|
||||
└─────────────────────────┬──────────────────────────────────┘
|
||||
┌─────────────┬────┴────────┬─────────────┐
|
||||
@@ -232,56 +220,18 @@ No combo to create. Set your model to `auto` (or a variant) and OmniRoute builds
|
||||
|
||||
##
|
||||
|
||||
### 🔀 Or build your own — 18 routing strategies
|
||||
### 🔀 Or build your own — 15 routing strategies
|
||||
|
||||
All **18** strategies — mix & match per combo step:
|
||||
| Goal | Strategy / combo |
|
||||
| --------------------------------------- | -------------------------------------------------- |
|
||||
| 🥇 Drain my subscription before paying | `priority` / `fill-first` |
|
||||
| ⚖️ Spread load across accounts | `round-robin` · `weighted` · `p2c` · `least-used` |
|
||||
| 💸 Always cheapest viable model | `cost-optimized` · `auto/cheap` |
|
||||
| 🧠 Hand off long context between models | `context-relay` · `context-optimized` |
|
||||
| 🎲 Randomized / privacy routing | `random` · `strict-random` |
|
||||
| 🤖 Just make it smart | `auto` (9-factor scoring) · `lkgp` · `reset-aware` |
|
||||
|
||||
| # | Strategy | What it does |
|
||||
| --- | ------------------- | ---------------------------------------------------------------- |
|
||||
| 1 | `priority` | First-target ordered list — drain each before the next 🥇 |
|
||||
| 2 | `fill-first` | Fill each target's quota fully before moving on |
|
||||
| 3 | `weighted` | Weighted random by per-target weight |
|
||||
| 4 | `round-robin` | Cycle through targets in order |
|
||||
| 5 | `p2c` | Power-of-two-choices random load balancing |
|
||||
| 6 | `least-used` | Pick the target with the lowest current load |
|
||||
| 7 | `random` | Uniform random pick (deduplicated) |
|
||||
| 8 | `strict-random` | Random without de-duplicating repeats 🎲 |
|
||||
| 9 | `cost-optimized` | Minimize $ per request from live catalog pricing 💸 |
|
||||
| 10 | `headroom` | Pick the target with the most remaining quota |
|
||||
| 11 | `reset-window` | Prefer the target whose quota window resets soonest |
|
||||
| 12 | `reset-aware` | Rank by quota reset time — short windows first 📊 |
|
||||
| 13 | `context-relay` | Hand off context across targets for long conversations 🧠 |
|
||||
| 14 | `context-optimized` | Pick the best fit for the current context size |
|
||||
| 15 | `lkgp` | Last-Known-Good Path — sticky to the last successful target |
|
||||
| 16 | `auto` | 12-factor live scoring across every connection 🤖 |
|
||||
| 17 | `fusion` | Fan out to a panel of models + a judge synthesizes one answer 🧬 |
|
||||
| 18 | `pipeline` | Chain steps — each target's output feeds the next one 🔗 |
|
||||
|
||||
<sub>The Auto-Combo engine scores every candidate on **12 factors** (health, quota, cost, latency, success rate, freshness…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
|
||||
##
|
||||
|
||||
### ⚖️ Quota-Share — split one subscription across a team ✨ NEW
|
||||
|
||||
> Running several keys against the **same upstream account** (one Codex Pro plan, one Kimi key, one GLM Coding seat)? A burst on one key can burn the whole 5-hour / hourly quota and lock everyone else out. **Quota-Share** distributes a provider's time-based quota **fairly** across the keys in a pool — and it's _work-conserving_, so an idle member's slice is lent out instead of wasted.
|
||||
|
||||
| Knob | What it controls |
|
||||
| ------------------------ | ------------------------------------------------------------------------------- |
|
||||
| ⚖️ **Allocation weight** | each key's slice of the pool — e.g. `50 / 30 / 20` |
|
||||
| 📐 **Dimensions** | track `%` · requests · tokens · `$`, per **5h / 7d / per-model** window |
|
||||
| 🚦 **Policy** | `hard` (block over share) · `soft` (deprioritize) · `burst` (use idle headroom) |
|
||||
| 🧱 **Cap** | absolute ceiling per key, independent of mode |
|
||||
|
||||
```
|
||||
Pool "team-codex" · 1 Codex Pro account · 3 keys · 5-hour window
|
||||
├─ alice weight 50 ██████████░░░░░░░░░░ ≤ 50% of the shared 5h quota
|
||||
├─ bob weight 30 ██████░░░░░░░░░░░░░░ ≤ 30%
|
||||
└─ ci-bot weight 20 ████░░░░░░░░░░░░░░░░ ≤ 20%
|
||||
Generous mode (<50% pool used) → idle shares are lent out
|
||||
Strict mode (≥50% pool used) → each key held to its fair share
|
||||
```
|
||||
|
||||
<sub>Enforced in the hot path **before** the request leaves OmniRoute, with per-(key, model) caps + session stickiness for prompt-cache integrity (now with a per-combo / global disable toggle). 📖 [Quota Sharing Engine](docs/routing/QUOTA_SHARE.md)</sub>
|
||||
<sub>The Auto-Combo engine scores every candidate on **9 factors** (health, quota, cost, latency, success rate, freshness…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
|
||||
##
|
||||
|
||||
@@ -312,20 +262,20 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
</div>
|
||||
|
||||
| Feature | OmniRoute | Other routers |
|
||||
| -------------------------------------- | ------------------------------------------------------------------- | ------------- |
|
||||
| 🌐 Providers | **250** | 20–100 |
|
||||
| 🆓 Free providers | **90+ (11 free forever)** | 1–5 |
|
||||
| 🔀 Routing strategies | **18** (priority, weighted, cost-optimized, context-relay, fusion…) | 1–3 |
|
||||
| 🗜️ Token compression | **RTK + Caveman stacked (15–95%)** | None / 20–40% |
|
||||
| 🧰 Built-in MCP server | **94 tools, 3 transports, 30 scopes** | Rare |
|
||||
| 🤝 A2A agent protocol | **6 skills, JSON-RPC 2.0** | None |
|
||||
| 🧠 Memory (FTS5 + vector) | **Yes** | Rare |
|
||||
| 🛡️ Guardrails (PII, injection, vision) | **Yes** | Rare |
|
||||
| ☁️ Cloud agents | **Codex, Cursor, Devin, Jules** | None |
|
||||
| 🥷 TLS fingerprint stealth | **JA3/JA4 via wreq-js** | None |
|
||||
| 🖥️ Multi-platform | **Web · Desktop · Termux · PWA** | Web only |
|
||||
| 🌍 i18n | **42 locales** | 0–4 |
|
||||
| Feature | OmniRoute | Other routers |
|
||||
| -------------------------------------- | ----------------------------------------------------------- | ------------- |
|
||||
| 🌐 Providers | **227** | 20–100 |
|
||||
| 🆓 Free providers | **50+ (11 free forever)** | 1–5 |
|
||||
| 🔀 Routing strategies | **15** (priority, weighted, cost-optimized, context-relay…) | 1–3 |
|
||||
| 🗜️ Token compression | **RTK + Caveman stacked (15–95%)** | None / 20–40% |
|
||||
| 🧰 Built-in MCP server | **87 tools, 3 transports, 30 scopes** | Rare |
|
||||
| 🤝 A2A agent protocol | **6 skills, JSON-RPC 2.0** | None |
|
||||
| 🧠 Memory (FTS5 + vector) | **Yes** | Rare |
|
||||
| 🛡️ Guardrails (PII, injection, vision) | **Yes** | Rare |
|
||||
| ☁️ Cloud agents | **Codex, Devin, Jules** | None |
|
||||
| 🥷 TLS fingerprint stealth | **JA3/JA4 via wreq-js** | None |
|
||||
| 🖥️ Multi-platform | **Web · Desktop · Termux · PWA** | Web only |
|
||||
| 🌍 i18n | **42 locales** | 0–4 |
|
||||
|
||||
<sub>📊 Detailed comparison vs LiteLLM, OpenRouter & Portkey → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
@@ -333,32 +283,6 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
<div align="center">
|
||||
|
||||
# ✨ What's New
|
||||
|
||||
</div>
|
||||
|
||||
> Recent highlights from **v3.8.20 → v3.8.47**. Full history in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
- **🗜️ Compression hardening** — a default-on **inflation guard** (discard the stacked result and send the verbatim original whenever compression would _grow_ the prompt), completed **Caveman rule packs** for German / French / Japanese (dedup + ultra) plus a new **Chinese (文言 / wényán) input pack** with zh-vs-ja auto-detection, and **RTK filters for Gradle & .NET (`dotnet`)** build output. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||
- **💸 Honest flat-rate cost** — subscription / coding-plan providers (ChatGPT Web, grok-web, the Minimax / Kimi / GLM / Alibaba Coding plans, Xiaomi MiMo…) now read **$0** in cost analytics instead of an inflated per-token estimate, while budget / quota / routing keep estimating unchanged. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **⚖️ Quota-Share routing** — a dedicated combo strategy that spreads load across accounts by _available quota_: Deficit-Round-Robin scheduling, per-connection `max_concurrent` with cooldown-wait queueing, multi-window usage buckets (5h / 7d / per-model), per-(key, model) caps, session stickiness for prompt-cache integrity (now with a per-combo / global disable toggle), and proactive saturation from upstream token-usage headers. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
||||
- **🤖 One-command CLI/agent setup** — a dedicated `setup-*` command configures each coding tool to route through OmniRoute (Claude Code, Codex, Cline, Continue, Cursor, Roo Code, Kilo Code, Crush, Goose, Qwen Code, Aider, OpenCode); `omniroute launch` / `omniroute launch-codex` are zero-config launchers. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
- **🛰️ Remote mode** — drive a remote OmniRoute from any machine with scoped access tokens (`omniroute connect` / `omniroute contexts` / `omniroute tokens`), plus an `omniroute login antigravity` helper that runs Google "native/desktop" OAuth on your own machine and pastes a credential blob into a remote/VPS install (where the loopback redirect is unreachable). → [Remote Mode](docs/guides/REMOTE-MODE.md)
|
||||
- **🧭 Smarter auto-routing** — OpenRouter-style `auto/<category>:<tier>` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), a **Fusion** strategy (fan out to a panel of models in parallel, then synthesize via a judge), **task-aware routing** (best-fit connection per task type), per-request `X-Route-Model` override, live Arena-ELO + models.dev model intelligence, per-step account allowlists, provider-wildcard combo steps, nested combo-ref execution, sticky weighted selection, `web_search`-aware routing (now with **per-model web-search/web-fetch interception rules**), native **xAI Grok `/v1/responses`** routing, and **per-request Auto-Combo controls** (`X-OmniRoute-Mode` mode-preset override + `X-OmniRoute-Budget` hard USD cost ceiling, scoped to a single request). Embeddings-only and rerank-only models (JinaAI, OpenRouter custom, reranker models…) no longer disappear from the combo builder's model picker. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
- **🗜️ Pluggable compression** — an async pipeline of **10 composable engines** with Compression Studios, an LLMLingua-2 ONNX engine and a heuristic/SLM two-tier **Ultra**, RTK, delegated Anthropic Context Editing, **Output Styles** (output-axis steering: terse-prose / less-code / terse-CJK), an **adaptive context-budget dial** (escalate only as far as needed to fit the context window), per-request `x-omniroute-compression` control, an opt-in offline eval harness, one-click **Headroom** proxy lifecycle management from the dashboard (Docker sidecar supported), a synthetic **compression playground** (Play lanes + A/B Compare with USD-capped fidelity verdicts), an opt-in **per-step fidelity gate** that rejects a lossy engine before it degrades the prompt, a **best-of-N candidate encoder** (GCF vs TOON — keep whichever is shorter, with an A/B bytes/token table in the studio), the vendored **GCF codec updated to spec v3.2** (nested flattening — deeply-nested payloads go from ~3% to ~32% compression vs JSON), a new **omniglyph** engine (context-as-image, ~10× fewer tokens on the converted block), **CCR ranged/grep/stats retrieval** (pull an exact byte/line slice or summary of a stored block instead of re-expanding it), a unified panel with named profiles + an active-profile selector, an opt-in **per-engine pipeline circuit-breaker**, an opt-in **LLM-tier engine** (a model pass for higher-ratio semantic compression), a **read-lifecycle engine** that collapses superseded file reads, **usage-observed prefix freeze**, a graduated **CCR retrieval-feedback ramp**, a `preserveSystemPrompt` mode enum, and a **drag-reorder pipeline editor** in the studio. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||
- **🕵️ Transparent MITM decrypt (TPROXY)** — capture & translate traffic from CLIs that ignore proxy env vars, with a per-SNI certificate authority and a trust-store installer. → [MITM/TPROXY](docs/security/MITM-TPROXY-DECRYPT.md)
|
||||
- **💸 Cost telemetry everywhere** — `X-OmniRoute-*` cost/usage headers on every endpoint (including media), a non-token cost engine, a cache-HIT `X-OmniRoute-Cost-Saved` header, and per-key USD spend quotas. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🧠 Memory you control** — opt-in int8 vector quantization (Qdrant + sqlite-vec), opt-in **typed memory decay** (aged low-value memories fade on a per-type schedule), memory off by default, and a per-request `x-omniroute-no-memory` header. → [Memory](docs/frameworks/MEMORY.md)
|
||||
- **🛡️ Security** — a prompt-injection guard across every LLM route (backed by a red-team suite), plus a free DuckDuckGo last-resort web search. → [Guardrails](docs/security/GUARDRAILS.md)
|
||||
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style audio translation) round out the media API surface. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🌍 Deployment & ops** — reverse-proxy `basePath` deployment (`OMNIROUTE_BASE_PATH`, e.g. serving OmniRoute under `/omniroute/`), browser-language auto-detect on first visit, per-API-key device/connection tracking (IP+UA fingerprint, masked, in-memory only), root-less MITM cert trust for user-namespaced containers (`OMNIROUTE_NO_SUDO`), server-side configured-only / available-only filters on the Free Provider Rankings page, and **Traditional Chinese (zh-TW)** localization for the frontend + CLI. → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, new gateways **DGrid** and **Pioneer AI** (Fastino Labs), inbound **xAI Grok** translators plus **Grok Build (xAI)** with an OAuth import-token flow, GPT-4 / GPT-4o-mini on the GitHub Copilot provider, multi-model **Factory Droid**, **ZenMux Free** (session-cookie free tier), **Alibaba DashScope** text-to-video (`wan2.7-t2v`), a refreshed 250-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech/transcription/music/video), a first-class **Ollama** local-provider card, the **SenseNova** free Token Plan (chat + text-to-image), one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`), **Claude Sonnet 5** wired end-to-end, a new provider wave (**Kenari**, **SumoPod**, **X5Lab**, **Charm Hyper**, **Nube.sh**, **b.ai**, **Qiniu**, **ModelScope**, **Augment/Auggie CLI**, **ClinePass**, NVIDIA NIM image generation), Codex account import from a raw ChatGPT access token, the **Requesty** gateway (BYOK, ~200 free req/day), **Yuanbao (web)** as a cookie-session provider (DeepSeek V3/R1 + Hunyuan), the **Zed** hosted LLM aggregator (OAuth), **Claude 5 Sonnet** on the Claude Web provider, Kiro **adaptive-thinking reasoning** surfaced as `reasoning_content`, **bulk API-key add for Cloudflare Workers AI**, and **OpenVecta** (AI inference gateway). → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **⚡ Local performance & infra** — a one-click local Redis launcher (`omniroute redis up`, plus a dashboard Redis panel), one-click **Cloudflare Workers** and **Deno Deploy** relay deployers wired into the proxy pool, a relay-backend selector (`OMNIROUTE_RELAY_BACKEND=ts|bifrost|auto`) so `/v1/relay` stays the stable surface while choosing the fastest backend internally, **Bifrost** (Go AI-gateway) and **Mux** (agent-orchestration daemon) promoted to first-class embedded/supervised services alongside 9Router/CLIProxyAPI, **Webshare** added as a paid fourth source in the free-proxy provider framework, and **shorthand proxy formats + protocol header mode** for bulk proxy import. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 🤖 Compatible CLIs & Coding Agents
|
||||
|
||||
> One config — `http://localhost:20128/v1` — and **every** AI IDE or CLI runs on free & low-cost models.
|
||||
@@ -368,6 +292,7 @@ Result: 4 layers of fallback = zero downtime
|
||||
<tr>
|
||||
<td align="center" width="120"><a href="https://github.com/anthropics/claude-code"><img src="./public/providers/claude.svg" width="52" alt="Claude Code"/><br/><b>Claude Code</b></a></td>
|
||||
<td align="center" width="120"><a href="https://github.com/openai/codex"><img src="./public/providers/codex.svg" width="52" alt="Codex CLI"/><br/><b>Codex CLI</b></a></td>
|
||||
<td align="center" width="120"><a href="https://github.com/google-gemini/gemini-cli"><img src="./public/providers/gemini-cli.svg" width="52" alt="Gemini CLI"/><br/><b>Gemini CLI</b></a></td>
|
||||
<td align="center" width="120"><img src="./public/providers/cursor.png" width="52" alt="Cursor"/><br/><b>Cursor</b></td>
|
||||
<td align="center" width="120"><img src="./public/providers/copilot.png" width="52" alt="Copilot"/><br/><b>Copilot</b></td>
|
||||
<td align="center" width="120"><img src="./public/providers/continue.png" width="52" alt="Continue"/><br/><b>Continue</b></td>
|
||||
@@ -387,7 +312,7 @@ Result: 4 layers of fallback = zero downtime
|
||||
<b>+ also works with</b> · Cline · Antigravity · Windsurf · AMP · Hermes · Qwen CLI · Roo · Continue · <b>any OpenAI-compatible tool</b>
|
||||
</div>
|
||||
|
||||
<sub>📖 Per-tool setup for all 24+ tools → [`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 16+ tools → [`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>
|
||||
|
||||
@@ -395,60 +320,28 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 🌐 250 AI Providers — 90+ Free
|
||||
# 🌐 227 AI Providers — 50+ Free
|
||||
|
||||
</div>
|
||||
|
||||
> The most complete catalog of any open-source router: **250 providers**, **90+ with a free tier**, **11 free forever**.
|
||||
> The most complete catalog of any open-source router: **227 providers**, **50+ with a free tier**, **11 free forever**.
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 🏢 Every major lab — through one endpoint
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="92"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/openai.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/openai.svg" width="40" alt="OpenAI"/></picture><br/><sub>OpenAI</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/claude-color.svg" width="40" alt="Anthropic"/><br/><sub>Anthropic</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/gemini-color.svg" width="40" alt="Gemini"/><br/><sub>Gemini</sub></td>
|
||||
<td align="center" width="92"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/grok.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/grok.svg" width="40" alt="xAI Grok"/></picture><br/><sub>xAI Grok</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/deepseek-color.svg" width="40" alt="DeepSeek"/><br/><sub>DeepSeek</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/mistral-color.svg" width="40" alt="Mistral"/><br/><sub>Mistral</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/qwen-color.svg" width="40" alt="Qwen"/><br/><sub>Qwen</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/meta-color.svg" width="40" alt="Meta Llama"/><br/><sub>Meta Llama</sub></td>
|
||||
<td align="center" width="92"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/groq.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/groq.svg" width="40" alt="Groq"/></picture><br/><sub>Groq</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/nvidia-color.svg" width="40" alt="NVIDIA"/><br/><sub>NVIDIA</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/minimax-color.svg" width="40" alt="MiniMax"/><br/><sub>MiniMax</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/cohere-color.svg" width="40" alt="Cohere"/><br/><sub>Cohere</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/perplexity-color.svg" width="40" alt="Perplexity"/><br/><sub>Perplexity</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/huggingface-color.svg" width="40" alt="Hugging Face"/><br/><sub>HuggingFace</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/together-color.svg" width="40" alt="Together"/><br/><sub>Together</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/fireworks-color.svg" width="40" alt="Fireworks"/><br/><sub>Fireworks</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/cloudflare-color.svg" width="40" alt="Cloudflare"/><br/><sub>Cloudflare</sub></td>
|
||||
<td align="center" width="92"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/baidu-color.svg" width="40" alt="Baidu"/><br/><sub>Baidu</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>…and 220+ more — every icon resolves live from the dashboard's provider catalog. 📖 [Provider Reference](docs/reference/PROVIDER_REFERENCE.md)</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
### 🆓 Free Forever — $0, no card
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="150"><img src="./public/providers/agentrouter.png" width="44" alt="AgentRouter"/><br/><b>AgentRouter</b><br/><sub>GPT-5, Claude, Gemini<br/>$100 free credits</sub></td>
|
||||
<td align="center" width="150"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/qoder-color.svg" width="44" alt="Qoder AI"/><br/><b>Qoder AI</b><br/><sub>Kimi-K2, DeepSeek-R1<br/>Unlimited FREE</sub></td>
|
||||
<td align="center" width="150"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/pollinations.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/pollinations.svg" width="44" alt="Pollinations"/></picture><br/><b>Pollinations</b><br/><sub>GPT-5, Claude, Llama 4<br/>No key needed</sub></td>
|
||||
<td align="center" width="150"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/longcat-color.svg" width="44" alt="LongCat"/><br/><b>LongCat</b><br/><sub>LongCat-2.0<br/>10M tokens one-time (KYC) 🔑</sub></td>
|
||||
<td align="center" width="150"><img src="https://img.shields.io/badge/AgentRouter-FF6600?style=flat-square" alt="AgentRouter"/><br/><sub>GPT-5, Claude, Gemini<br/>$100 free credits</sub></td>
|
||||
<td align="center" width="150"><img src="https://img.shields.io/badge/Qoder_AI-6366F1?style=flat-square" alt="Qoder AI"/><br/><sub>Kimi-K2, DeepSeek-R1<br/>Unlimited FREE</sub></td>
|
||||
<td align="center" width="150"><img src="https://img.shields.io/badge/Pollinations-10B981?style=flat-square" alt="Pollinations"/><br/><sub>GPT-5, Claude, Llama 4<br/>No key needed</sub></td>
|
||||
<td align="center" width="150"><img src="https://img.shields.io/badge/LongCat-FF7A00?style=flat-square" alt="LongCat"/><br/><sub>Flash-Lite<br/>50M tokens/day 🔥</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/cloudflare-color.svg" width="44" alt="Cloudflare AI"/><br/><b>Cloudflare AI</b><br/><sub>50+ models<br/>10K neurons/day</sub></td>
|
||||
<td align="center" width="150"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/nvidia-color.svg" width="44" alt="NVIDIA NIM"/><br/><b>NVIDIA NIM</b><br/><sub>129 models<br/>~40 RPM free</sub></td>
|
||||
<td align="center" width="150"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/cerebras-color.svg" width="44" alt="Cerebras"/><br/><b>Cerebras</b><br/><sub>Qwen3 235B<br/>1M tokens/day</sub></td>
|
||||
<td align="center" width="150"><img src="https://img.shields.io/badge/Cloudflare_AI-F38020?style=flat-square&logo=cloudflare&logoColor=white" alt="Cloudflare AI"/><br/><sub>50+ models<br/>10K neurons/day</sub></td>
|
||||
<td align="center" width="150"><img src="https://img.shields.io/badge/Gemini_CLI-8E75B2?style=flat-square&logo=googlegemini&logoColor=white" alt="Gemini CLI"/><br/><sub>gemini-3-flash<br/>180K/mo free</sub></td>
|
||||
<td align="center" width="150"><img src="https://img.shields.io/badge/NVIDIA_NIM-76B900?style=flat-square&logo=nvidia&logoColor=white" alt="NVIDIA NIM"/><br/><sub>129 models<br/>~40 RPM free</sub></td>
|
||||
<td align="center" width="150"><img src="https://img.shields.io/badge/Cerebras-F15A29?style=flat-square" alt="Cerebras"/><br/><sub>Qwen3 235B<br/>1M tokens/day</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -465,16 +358,16 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
> Same app, your machine, your rules. From a global npm install to **your phone** via Termux.
|
||||
|
||||
| Platform | Install | Highlights |
|
||||
| ------------------------- | ---------------------------------------- | --------------------------------------------------------- |
|
||||
| 📦 **npm (global)** | `npm install -g omniroute` | One command, any OS |
|
||||
| 🐳 **Docker** | `docker run … diegosouzapw/omniroute` | Multi-arch **AMD64 + ARM64** |
|
||||
| 🖥️ **Desktop (Electron)** | `npm run electron:build` | Native window + system tray — **Windows / macOS / Linux** |
|
||||
| 💪 **ARM** | native `arm64` | Raspberry Pi, ARM servers, Apple Silicon |
|
||||
| 📱 **Android (Termux)** | `pkg install nodejs && npx -y omniroute` | Runs **on your phone**, 24/7, no root |
|
||||
| 📲 **PWA** | "Add to Home Screen" | Fullscreen, offline, installable from browser |
|
||||
| 🧩 **OpenCode plugin** | `@omniroute/opencode-provider` | Native OpenCode integration |
|
||||
| 🛠️ **From source** | `npm install && npm run dev` | Hack on it, contribute |
|
||||
| Platform | Install | Highlights |
|
||||
| ------------------------- | -------------------------------------------- | --------------------------------------------------------- |
|
||||
| 📦 **npm (global)** | `npm install -g omniroute` | One command, any OS |
|
||||
| 🐳 **Docker** | `docker run … diegosouzapw/omniroute` | Multi-arch **AMD64 + ARM64** |
|
||||
| 🖥️ **Desktop (Electron)** | `npm run electron:build` | Native window + system tray — **Windows / macOS / Linux** |
|
||||
| 💪 **ARM** | native `arm64` | Raspberry Pi, ARM servers, Apple Silicon |
|
||||
| 📱 **Android (Termux)** | `pkg install nodejs-lts && npx -y omniroute` | Runs **on your phone**, 24/7, no root |
|
||||
| 📲 **PWA** | "Add to Home Screen" | Fullscreen, offline, installable from browser |
|
||||
| 🧩 **OpenCode plugin** | `@omniroute/opencode-provider` | Native OpenCode integration |
|
||||
| 🛠️ **From source** | `npm install && npm run dev` | Hack on it, contribute |
|
||||
|
||||
<sub>📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md)</sub>
|
||||
|
||||
@@ -504,7 +397,7 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
</div>
|
||||
|
||||
> OmniRoute isn't just a server — it's a **full command-line cockpit** with **80+ commands**, plus open agent protocols so an AI agent can drive OmniRoute **by itself**.
|
||||
> OmniRoute isn't just a server — it's a **full command-line cockpit** with **60+ commands**, plus open agent protocols so an AI agent can drive OmniRoute **by itself**.
|
||||
|
||||
### ⌨️ A real CLI (not just `start`)
|
||||
|
||||
@@ -515,22 +408,6 @@ omniroute setup # guided first-run wizard
|
||||
omniroute doctor # diagnose providers, ports, native deps
|
||||
```
|
||||
|
||||
### 🛰️ Remote mode — run the CLI here, OmniRoute on a VPS
|
||||
|
||||
OmniRoute on a server? Drive it from your laptop with the **same CLI**. Log in once
|
||||
with a scoped access token; every command then targets the remote.
|
||||
|
||||
```bash
|
||||
omniroute connect 192.168.0.15 # password → scoped token, saved as a context
|
||||
omniroute models list # ← runs against the REMOTE server
|
||||
omniroute configure codex # ← picks a remote model, writes a local Codex profile
|
||||
omniroute tokens create --name ci --scope read # mint narrower tokens for other machines
|
||||
omniroute contexts use default # ← switch back to the local server
|
||||
```
|
||||
|
||||
Tokens are scoped `read` / `write` / `admin`; process-spawning routes stay loopback-only.
|
||||
<sub>📖 [Remote Mode](docs/guides/REMOTE-MODE.md)</sub>
|
||||
|
||||
<div align="center">
|
||||
|
||||
`providers` · `oauth` · `keys` · `combo` · `nodes` · `models` · `cache` · `compression` · `cost` · `usage` · `quota` · `health` · `resilience` · `telemetry` · `logs` · `audit` · `mcp` · `a2a` · `cloud` · `memory` · `skills` · `eval` · `tunnel` · `backup` · `sync` · `webhooks` · `policy` · `pricing` · `translator` · `simulate` …
|
||||
@@ -544,7 +421,7 @@ Expose OmniRoute over **MCP** or **A2A** and any capable agent gets the keys to
|
||||
| Protocol | Endpoint | Use it for |
|
||||
| ------------------ | ----------------------------------------------- | ------------------------------------------------------ |
|
||||
| 🧰 **MCP (stdio)** | `omniroute --mcp` | Plug into Claude Desktop, Cursor, any MCP client |
|
||||
| 🌊 **MCP (HTTP)** | `http://localhost:20128/api/mcp/stream` | Remote MCP — **94 tools**, 30 scopes, full audit trail |
|
||||
| 🌊 **MCP (HTTP)** | `http://localhost:20128/api/mcp/stream` | Remote MCP — **87 tools**, 30 scopes, full audit trail |
|
||||
| 📡 **MCP (SSE)** | `http://localhost:20128/api/mcp/sse` | Streaming MCP transport |
|
||||
| 🤝 **A2A** | `http://localhost:20128/.well-known/agent.json` | Agent-to-agent, **JSON-RPC 2.0** + SSE, 6 skills |
|
||||
|
||||
@@ -563,9 +440,9 @@ claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp
|
||||
|
||||
</div>
|
||||
|
||||
> **Why use many tokens when few tokens do the trick?** Every request passes through OmniRoute's compression pipeline **transparently** — no client changes. It's now a **stack of 10 composable engines** that run in order and mix & match per routing combo — building on ideas from [RTK](https://github.com/rtk-ai/rtk), [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 78K+), [LLMLingua-2](https://github.com/microsoft/LLMLingua), and [Troglodita](https://github.com/leninejunior/troglodita) (PT-BR).
|
||||
> **Why use many token when few token do trick?** Every request passes through OmniRoute's compression pipeline **transparently** — no client changes. It's now a **stack of 9 composable engines** that run in order and mix & match per routing combo — building on ideas from [RTK](https://github.com/rtk-ai/rtk), [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 51K+), [LLMLingua-2](https://github.com/microsoft/LLMLingua), and [Troglodita](https://github.com/leninejunior/troglodita) (PT-BR).
|
||||
|
||||
### 🧱 The 10-engine stack
|
||||
### 🧱 The 9-engine stack
|
||||
|
||||
Engines run in pipeline order; each is independently toggleable and configurable per combo:
|
||||
|
||||
@@ -574,13 +451,12 @@ Engines run in pipeline order; each is independently toggleable and configurable
|
||||
| 1 | **Session-Dedup** | Drops content repeated across turns (content-addressed, cross-turn) |
|
||||
| 2 | **CCR** | Archives large blocks behind retrieve markers, fetched on demand |
|
||||
| 3 | **RTK** | Smart tool-result filtering, dedup & truncation (command-aware) |
|
||||
| 4 | **Headroom** | Lossless tabular compaction of homogeneous JSON arrays, flat or nested (~30%), via a vendored **GCF** codec (spec v3.2) |
|
||||
| 5 | **Relevance** | Extractive sentence scoring against the last user query |
|
||||
| 6 | **Caveman** | Rule-based prose compression (~65–75% on output) |
|
||||
| 7 | **LLMLingua-2** | ML semantic pruning via MobileBERT ONNX — code-safe, async |
|
||||
| 8 | **Lite** | Whitespace + image-URL trimming (latency-light baseline) |
|
||||
| 9 | **Aggressive** | Summarization + progressive aging of old turns |
|
||||
| 10 | **Ultra** | Heuristic token pruning with an optional small-model (SLM) tier |
|
||||
| 4 | **Headroom** | Lossless tabular compaction of homogeneous JSON arrays (~30%+) |
|
||||
| 5 | **Caveman** | Rule-based prose compression (~65–75% on output) |
|
||||
| 6 | **LLMLingua-2** | ML semantic pruning via MobileBERT ONNX — code-safe, async |
|
||||
| 7 | **Lite** | Whitespace + image-URL trimming (latency-light baseline) |
|
||||
| 8 | **Aggressive** | Summarization + progressive aging of old turns |
|
||||
| 9 | **Ultra** | Heuristic token pruning with an optional small-model (SLM) tier |
|
||||
|
||||
Code blocks, URLs and structured data are **always preserved** byte-perfect. **One-click presets** combine the engines:
|
||||
|
||||
@@ -614,7 +490,7 @@ Code blocks, URLs and structured data are **always preserved** byte-perfect. **O
|
||||
### 📖 How it works — pipeline, architecture & savings math
|
||||
|
||||
```
|
||||
Client (10,000 tok) ──▶ OmniRoute Compression (10 engines) ──▶ Provider (~1,080 tok, up to 95% saved)
|
||||
Client (10,000 tok) ──▶ OmniRoute Compression (9 engines) ──▶ Provider (~1,080 tok, up to 95% saved)
|
||||
```
|
||||
|
||||
Default stacked combo runs `RTK → Caveman`. When both act on the same tool/context payload, savings compound:
|
||||
@@ -625,20 +501,7 @@ average = 1 − (1 − 0.80) × (1 − 0.46) = 89.2%
|
||||
range = 78.4 – 94.6%
|
||||
```
|
||||
|
||||
Code blocks, URLs, JSON and structured data are **always protected** by the preservation engine.
|
||||
|
||||
### 🎚️ Beyond the engines — output styles, the adaptive dial & per-request control
|
||||
|
||||
The 10 engines above shrink what goes **in**. Three more layers shape **how**, **when**, and what comes **out**:
|
||||
|
||||
- **🪄 Output Styles** _(output-axis steering)_ — inject deterministic, cache-safe response-shaping instructions; combinable, each at `lite` / `full` / `ultra` intensity. Adding a style is a one-line registry entry:
|
||||
- **Terse prose** — drop filler / articles / hedging; keep technical substance exact.
|
||||
- **Less code** — "lazy senior dev" YAGNI: smallest working change, no unrequested scaffolding.
|
||||
- **Terse CJK (文言)** — classical-Chinese ultra-terse style (locale-gated to `zh`).
|
||||
- **🎯 Adaptive context-budget** _(the dial)_ — instead of one on/off token threshold, escalate the cheapest, most-lossless engines only as far as needed to **fit the model's context window**. Policy: `reserve-output` (default, model-aware) · `percentage` · `absolute`. Mode: `floor` (guarantee fit) · `replace-autotrigger` (your explicit choice wins) · `off` (legacy threshold).
|
||||
- **🎛️ Where compression is decided** _(precedence, high → low)_ — per-request `x-omniroute-compression` header › routing-combo override › active named profile › adaptive / auto-trigger › panel default › off. The applied plan echoes back in the `X-OmniRoute-Compression: <mode>; source=<source>` response header.
|
||||
|
||||
Auto-trigger by token threshold, flip on the adaptive dial, pin a named profile, set a one-off per request, or assign a pipeline per routing combo — whichever fits the workload. An opt-in offline **eval harness** (`npm run eval:compression`) scores fidelity vs. savings on a pinned corpus before you promote a change.
|
||||
Code blocks, URLs, JSON and structured data are **always protected** by the preservation engine. Auto-trigger compression by token threshold, or assign a compression pipeline per routing combo.
|
||||
|
||||
📖 [`COMPRESSION_GUIDE.md`](docs/compression/COMPRESSION_GUIDE.md) · [`RTK_COMPRESSION.md`](docs/compression/RTK_COMPRESSION.md) · [`COMPRESSION_ENGINES.md`](docs/compression/COMPRESSION_ENGINES.md)
|
||||
|
||||
@@ -661,7 +524,7 @@ Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`.
|
||||
|
||||
**2) Connect a FREE provider (no signup)**
|
||||
|
||||
Dashboard → **Providers** → connect **Kiro AI** (free Claude, ~50 credits/month per account) or **OpenCode Free** (no auth) → done.
|
||||
Dashboard → **Providers** → connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → done.
|
||||
|
||||
**3) Point your coding tool**
|
||||
|
||||
@@ -713,7 +576,7 @@ PORT=20128 npm run dev
|
||||
**📦 pnpm**
|
||||
|
||||
```bash
|
||||
pnpm add -g omniroute@latest --allow-build=better-sqlite3 --allow-build=@swc/core && omniroute
|
||||
pnpm install -g omniroute && pnpm approve-builds -g && omniroute
|
||||
```
|
||||
|
||||
**🐧 Arch Linux (AUR)**
|
||||
@@ -751,24 +614,6 @@ podman compose --profile base up -d
|
||||
|
||||
📖 [Podman Guide](contrib/podman/README.md) — Quadlet setup, podman-compose, Quadlet.
|
||||
|
||||
**⚡ Faster / leaner install (skip the native build)**
|
||||
|
||||
The native SQLite engine (`better-sqlite3`) is an **optional** dependency, so a global
|
||||
install never blocks on compiling from source: it uses a prebuilt binary when one matches
|
||||
your platform/Node, and otherwise falls back transparently to a pure-JS engine
|
||||
(`node:sqlite` on Node 22+, else the bundled `sql.js` WASM) — no build tools required.
|
||||
|
||||
To skip the post-install native warm-up entirely (CI, headless, or slow machines):
|
||||
|
||||
```bash
|
||||
OMNIROUTE_SKIP_POSTINSTALL=1 npm install -g omniroute # CI=1 also skips it
|
||||
```
|
||||
|
||||
For the fastest installs prefer **pnpm** (content-addressed store + hard links — see above).
|
||||
For a dashboard-free, headless runtime use the Docker `base` profile (above) or the
|
||||
[Termux guide](docs/guides/TERMUX_GUIDE.md). The CLI and the web dashboard are served by the
|
||||
same process on one port, so there is no separate CLI-only package today.
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
@@ -823,16 +668,16 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
|
||||
**The $0 Free Stack — combine into one unbreakable combo:**
|
||||
|
||||
| Provider | Prefix | Free models | Quota |
|
||||
| ----------------- | ----------- | ----------------------------------------------- | ------------------ |
|
||||
| **Kiro** | `kr/` | Claude Sonnet 4.5, Haiku 4.5, Opus 4.6 | 50 credits/mo |
|
||||
| **Qoder** | `if/` | kimi-k2-thinking, qwen3-coder-plus, deepseek-r1 | ♾️ Unlimited |
|
||||
| **Qwen** | `qw/` | qwen3-coder-plus/flash/next | ♾️ Unlimited |
|
||||
| **Pollinations** | `pol/` | GPT-5, Claude, Gemini, DeepSeek, Llama 4 | No key needed |
|
||||
| **LongCat** | `lc/` | LongCat-2.0 | 10M one-time (KYC) |
|
||||
| **Cloudflare AI** | `cf/` | 50+ models | 10K neurons/day |
|
||||
| **NVIDIA NIM** | `nvidia/` | 129 models | ~40 RPM |
|
||||
| **Cerebras** | `cerebras/` | Qwen3 235B, GPT-OSS 120B | 1M tok/day |
|
||||
| Provider | Prefix | Free models | Quota |
|
||||
| ----------------- | ----------- | ----------------------------------------------- | ----------------- |
|
||||
| **Kiro** | `kr/` | Claude Sonnet 4.5, Haiku 4.5, Opus 4.6 | 50 credits/mo |
|
||||
| **Qoder** | `if/` | kimi-k2-thinking, qwen3-coder-plus, deepseek-r1 | ♾️ Unlimited |
|
||||
| **Qwen** | `qw/` | qwen3-coder-plus/flash/next | ♾️ Unlimited |
|
||||
| **Pollinations** | `pol/` | GPT-5, Claude, Gemini, DeepSeek, Llama 4 | No key needed |
|
||||
| **LongCat** | `lc/` | LongCat-Flash-Lite | 50M tokens/day 🔥 |
|
||||
| **Cloudflare AI** | `cf/` | 50+ models | 10K neurons/day |
|
||||
| **NVIDIA NIM** | `nvidia/` | 129 models | ~40 RPM |
|
||||
| **Cerebras** | `cerebras/` | Qwen3 235B, GPT-OSS 120B | 1M tok/day |
|
||||
|
||||
> 💡 The dashboard "cost" is a **savings tracker**, not a bill — OmniRoute never charges you. A "$290 total cost" using free models means **$290 saved**.
|
||||
|
||||
@@ -848,10 +693,10 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
**$0 forever:**
|
||||
|
||||
```
|
||||
1. kr/claude-sonnet-4.5 (Kiro — ~50 credits/mo per acct)
|
||||
1. kr/claude-sonnet-4.5 (Kiro — unlimited)
|
||||
2. if/kimi-k2-thinking (Qoder — unlimited)
|
||||
3. pol/gpt-5 (Pollinations — no key)
|
||||
4. lc/LongCat-2.0 (10M one-time backup, KYC)
|
||||
4. lc/longcat-flash-lite (50M tok/day backup)
|
||||
Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
```
|
||||
|
||||
@@ -881,9 +726,9 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
|
||||
<br/>
|
||||
|
||||
**Routing:** 18 strategies · task-aware smart routing · thinking budget controls · wildcard routing · system prompt injection.
|
||||
**Routing:** 15 strategies · task-aware smart routing · thinking budget controls · wildcard routing · system prompt injection.
|
||||
**Compatibility:** OpenAI ↔ Claude ↔ Gemini ↔ Responses API · auto OAuth refresh (PKCE, 8 providers) · multi-account round-robin · Batch + Files API · live OpenAPI 3.0.
|
||||
**Protocols:** MCP (94 tools, 3 transports, 30 scopes) · A2A (JSON-RPC 2.0, SSE, 6 skills) · ACP · cloud agents (Codex, Cursor, Devin, Jules).
|
||||
**Protocols:** MCP (87 tools, 3 transports, 30 scopes) · A2A (JSON-RPC 2.0, SSE, 6 skills) · ACP · cloud agents (Codex, Devin, Jules).
|
||||
**Plugins:** custom plugin marketplace (system-configured registry URL with SSRF-guarded fetch) · install / enable / disable · Notion + Obsidian knowledge-base integrations (WebDAV file server, vault search, note CRUD).
|
||||
**Embedded services:** one-click install & lifecycle management of local sidecar services (CLIProxy, NineRouter).
|
||||
**Quality & Ops:** built-in **Evals** (golden-set: exact/contains/regex/custom) · guardrails (PII, injection, vision) · health dashboard · p50/p95/p99 telemetry · webhooks · compliance audit.
|
||||
@@ -905,9 +750,9 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
| `DATA_DIR` | `~/.omniroute` | Database & config storage |
|
||||
|
||||
**Will I be charged by OmniRoute?** No — it's free, open-source software on your machine. You only pay paid providers directly. OmniRoute has no billing system.
|
||||
**Are FREE providers really unlimited?** Mostly — Qoder, Pollinations, LongCat, and Cloudflare are free with no per-account credit cap. Kiro is free too but capped at ~50 credits/month per account. Stack multiple free providers in a combo and auto-fallback keeps you serving for $0.
|
||||
**Are FREE providers really unlimited?** Yes — Kiro, Qoder, Pollinations, LongCat, Cloudflare. No catch.
|
||||
**Will compression hurt quality?** No — it only compresses the **input**; code, URLs, JSON are always protected.
|
||||
**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 250 providers.
|
||||
**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 227 providers.
|
||||
|
||||
📖 [User Guide](docs/guides/USER_GUIDE.md) · [API Reference](docs/reference/API_REFERENCE.md) · [Environment Config](docs/reference/ENVIRONMENT.md)
|
||||
|
||||
@@ -977,7 +822,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
- **Protocols**: MCP (stdio/HTTP) + A2A v0.3 (JSON-RPC 2.0 + SSE)
|
||||
- **Streaming**: Server-Sent Events (SSE) + WebSocket bridge (`/v1/ws`)
|
||||
- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys + MCP Scoped Authorization
|
||||
- **Testing**: Node.js test runner + Vitest (**21,000+ test cases** across 2,586 files — unit, integration, E2E, security, ecosystem)
|
||||
- **Testing**: Node.js test runner + Vitest (**14,965 test cases** across 517 files — unit, integration, E2E, security, ecosystem)
|
||||
- **Platforms**: Desktop (Electron), Android (Termux), PWA (any browser)
|
||||
- **CI/CD**: GitHub Actions (auto npm publish + Docker Hub on release)
|
||||
- **Website**: [omniroute.online](https://omniroute.online)
|
||||
@@ -995,56 +840,64 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
|
||||
### 📘 Getting Started
|
||||
|
||||
- **[User Guide](docs/guides/USER_GUIDE.md)** — Providers, combos, CLI integration, deployment
|
||||
- **[Setup Guide](docs/guides/SETUP_GUIDE.md)** — Full install methods, CLI tool configs, protocol setup, timeout tuning
|
||||
- **[CLI Tools Guide](docs/reference/CLI-TOOLS.md)** — Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot
|
||||
- **[Remote Mode](docs/guides/REMOTE-MODE.md)** — Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens
|
||||
- **[Claude Code Config](docs/guides/CLAUDE-CODE-CONFIGURATION.md)** — Point Claude Code at OmniRoute (local/remote) with `launch` + per-model profiles
|
||||
- **[Quick Start](README.md#-quick-start)** — 3-step install → connect → configure
|
||||
| Document | Description |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment |
|
||||
| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
|
||||
| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
|
||||
| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure |
|
||||
|
||||
### 🔧 Operations & Deployment
|
||||
|
||||
- **[Docker Guide](docs/guides/DOCKER_GUIDE.md)** — Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags
|
||||
- **[Podman Guide](contrib/podman/README.md)** — Quadlet systemd integration, podman-compose, SELinux
|
||||
- **[VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md)** — Complete guide: VM + nginx + Cloudflare setup
|
||||
- **[Fly.io Deployment](docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md)** — Deploy to Fly.io with persistent storage
|
||||
- **[Termux Guide](docs/guides/TERMUX_GUIDE.md)** — Run OmniRoute on Android via Termux
|
||||
- **[PWA Guide](docs/guides/PWA_GUIDE.md)** — Progressive Web App install, caching, architecture
|
||||
- **[Uninstall Guide](docs/guides/UNINSTALL.md)** — Clean removal for all install methods
|
||||
- **[Environment Config](docs/reference/ENVIRONMENT.md)** — Complete `.env` variables and references
|
||||
| Document | Description |
|
||||
| -------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| [Docker Guide](docs/guides/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags |
|
||||
| [Podman Guide](contrib/podman/README.md) | Quadlet systemd integration, podman-compose, SELinux |
|
||||
| [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup |
|
||||
| [Fly.io Deployment](docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md) | Deploy to Fly.io with persistent storage |
|
||||
| [Termux Guide](docs/guides/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux |
|
||||
| [PWA Guide](docs/guides/PWA_GUIDE.md) | Progressive Web App install, caching, architecture |
|
||||
| [Uninstall Guide](docs/guides/UNINSTALL.md) | Clean removal for all install methods |
|
||||
| [Environment Config](docs/reference/ENVIRONMENT.md) | Complete `.env` variables and references |
|
||||
|
||||
### 🧠 Features & Architecture
|
||||
|
||||
- **[Architecture](docs/architecture/ARCHITECTURE.md)** — System architecture, data flow, and internals
|
||||
- **[Compression Guide](docs/compression/COMPRESSION_GUIDE.md)** — 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked
|
||||
- **[RTK Compression](docs/compression/RTK_COMPRESSION.md)** — Command-output compression, filters, trust, verify, raw-output recovery
|
||||
- **[Compression Engines](docs/compression/COMPRESSION_ENGINES.md)** — Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces
|
||||
- **[Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md)** — JSON rule-pack schemas for Caveman and RTK filters
|
||||
- **[Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md)** — Language detection and Caveman rule-pack authoring
|
||||
- **[Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)** — Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing
|
||||
- **[Auto-Combo Engine](docs/routing/AUTO-COMBO.md)** — 12-factor scoring, mode packs, self-healing
|
||||
- **[Proxy Guide](docs/ops/PROXY_GUIDE.md)** — 3-level proxy system, 1proxy marketplace, registry CRUD
|
||||
- **[Free Tiers](docs/reference/FREE_TIERS.md)** — 25+ free API providers consolidated directory
|
||||
- **[Features Gallery](docs/guides/FEATURES.md)** — Visual dashboard tour with screenshots
|
||||
- **[Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md)** — Beginner-friendly codebase walkthrough
|
||||
| Document | Description |
|
||||
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [Architecture](docs/architecture/ARCHITECTURE.md) | System architecture, data flow, and internals |
|
||||
| [Compression Guide](docs/compression/COMPRESSION_GUIDE.md) | 7-option pipeline: off / lite / standard / aggressive / ultra / RTK / stacked |
|
||||
| [RTK Compression](docs/compression/RTK_COMPRESSION.md) | Command-output compression, filters, trust, verify, raw-output recovery |
|
||||
| [Compression Engines](docs/compression/COMPRESSION_ENGINES.md) | Caveman, RTK, stacked pipelines, dashboard/API/MCP surfaces |
|
||||
| [Compression Rules Format](docs/compression/COMPRESSION_RULES_FORMAT.md) | JSON rule-pack schemas for Caveman and RTK filters |
|
||||
| [Compression Language Packs](docs/compression/COMPRESSION_LANGUAGE_PACKS.md) | Language detection and Caveman rule-pack authoring |
|
||||
| [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) | Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing |
|
||||
| [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) | 9-factor scoring, mode packs, self-healing |
|
||||
| [Proxy Guide](docs/ops/PROXY_GUIDE.md) | 3-level proxy system, 1proxy marketplace, registry CRUD |
|
||||
| [Free Tiers](docs/reference/FREE_TIERS.md) | 25+ free API providers consolidated directory |
|
||||
| [Features Gallery](docs/guides/FEATURES.md) | Visual dashboard tour with screenshots |
|
||||
| [Codebase Documentation](docs/architecture/CODEBASE_DOCUMENTATION.md) | Beginner-friendly codebase walkthrough |
|
||||
|
||||
### 🤖 Protocols & APIs
|
||||
|
||||
- **[API Reference](docs/reference/API_REFERENCE.md)** — All endpoints with examples
|
||||
- **[OpenAPI Spec](docs/openapi.yaml)** — OpenAPI 3.0 specification
|
||||
- **[MCP Server](open-sse/mcp-server/README.md)** — 95 MCP tools, IDE configs, Python/TS/Go clients
|
||||
- **[MCP Server Guide](docs/frameworks/MCP-SERVER.md)** — MCP installation, transports, and tool reference
|
||||
- **[A2A Server](src/lib/a2a/README.md)** — JSON-RPC 2.0 protocol, skills, streaming, task mgmt
|
||||
- **[A2A Server Guide](docs/frameworks/A2A-SERVER.md)** — A2A agent card, tasks, skills, and streaming
|
||||
| Document | Description |
|
||||
| ------------------------------------------------- | --------------------------------------------------- |
|
||||
| [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples |
|
||||
| [OpenAPI Spec](docs/reference/openapi.yaml) | OpenAPI 3.0 specification |
|
||||
| [MCP Server](open-sse/mcp-server/README.md) | 87 MCP tools, IDE configs, Python/TS/Go clients |
|
||||
| [MCP Server Guide](docs/frameworks/MCP-SERVER.md) | MCP installation, transports, and tool reference |
|
||||
| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt |
|
||||
| [A2A Server Guide](docs/frameworks/A2A-SERVER.md) | A2A agent card, tasks, skills, and streaming |
|
||||
|
||||
### 📋 Project & Quality
|
||||
|
||||
- **[Contributing](CONTRIBUTING.md)** — Development setup and guidelines
|
||||
- **[Changelog](CHANGELOG.md)** — Full per-version release history
|
||||
- **[Security Policy](SECURITY.md)** — Vulnerability reporting and security practices
|
||||
- **[i18n Guide](docs/guides/I18N.md)** — 40+ language support, translation workflow, RTL
|
||||
- **[Release Checklist](docs/ops/RELEASE_CHECKLIST.md)** — Pre-release validation steps
|
||||
- **[Coverage Plan](docs/ops/COVERAGE_PLAN.md)** — Test coverage strategy and 21,000+ test suite
|
||||
| Document | Description |
|
||||
| -------------------------------------------------- | ----------------------------------------------- |
|
||||
| [Contributing](CONTRIBUTING.md) | Development setup and guidelines |
|
||||
| [Changelog](CHANGELOG.md) | Full per-version release history |
|
||||
| [Security Policy](SECURITY.md) | Vulnerability reporting and security practices |
|
||||
| [i18n Guide](docs/guides/I18N.md) | 40+ language support, translation workflow, RTL |
|
||||
| [Release Checklist](docs/ops/RELEASE_CHECKLIST.md) | Pre-release validation steps |
|
||||
| [Coverage Plan](docs/ops/COVERAGE_PLAN.md) | Test coverage strategy and 14,965 test suite |
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -1058,23 +911,23 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
<tr>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/oyi77">
|
||||
<img src="https://github.com/oyi77.png" width="40" style="border-radius:50%" alt="oyi77"/><br/>
|
||||
<img src="https://github.com/oyi77.png" width="80" style="border-radius:50%" alt="oyi77"/><br/>
|
||||
<b>oyi77</b>
|
||||
</a><br/>
|
||||
<sub>🥇 189 commits • +155K lines</sub><br/>
|
||||
<sub>🥇 190 commits • +72K lines</sub><br/>
|
||||
<sub>Analytics engine, SQL aggregations,<br/>proxy marketplace, test coverage</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/christopher-s">
|
||||
<img src="https://github.com/christopher-s.png" width="40" style="border-radius:50%" alt="Chris Staley"/><br/>
|
||||
<img src="https://github.com/christopher-s.png" width="80" style="border-radius:50%" alt="Chris Staley"/><br/>
|
||||
<b>Chris Staley</b>
|
||||
</a><br/>
|
||||
<sub>🥈 70 commits • +5.7K lines</sub><br/>
|
||||
<sub>🥈 72 commits • +5.7K lines</sub><br/>
|
||||
<sub>SSE stream hardening, Responses API,<br/>Gemini pagination, test regression fixes</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/zenobit">
|
||||
<img src="https://github.com/zenobit.png" width="40" style="border-radius:50%" alt="zenobit"/><br/>
|
||||
<img src="https://github.com/zenobit.png" width="80" style="border-radius:50%" alt="zenobit"/><br/>
|
||||
<b>zenobit</b>
|
||||
</a><br/>
|
||||
<sub>🥉 62 commits • +24K lines</sub><br/>
|
||||
@@ -1082,28 +935,20 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/rdself">
|
||||
<img src="https://github.com/rdself.png" width="40" style="border-radius:50%" alt="R.D. & Randi"/><br/>
|
||||
<img src="https://github.com/rdself.png" width="80" style="border-radius:50%" alt="R.D. & Randi"/><br/>
|
||||
<b>R.D. & Randi</b>
|
||||
</a><br/>
|
||||
<sub>🏅 108 commits • +30K lines</sub><br/>
|
||||
<sub>🏅 107 commits • +28K lines</sub><br/>
|
||||
<sub>Endpoints page, tunnel integrations,<br/>Docker workflows, A2A status, compression UI</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/benzntech">
|
||||
<img src="https://github.com/benzntech.png" width="40" style="border-radius:50%" alt="benzntech"/><br/>
|
||||
<img src="https://github.com/benzntech.png" width="80" style="border-radius:50%" alt="benzntech"/><br/>
|
||||
<b>benzntech</b>
|
||||
</a><br/>
|
||||
<sub>🏅 22 commits • +7.5K lines</sub><br/>
|
||||
<sub>🏅 20 commits • +7.5K lines</sub><br/>
|
||||
<sub>Electron desktop app, auto-updater,<br/>release build workflows, cross-platform CI</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/herjarsa">
|
||||
<img src="https://github.com/herjarsa.png" width="40" style="border-radius:50%" alt="herjarsa"/><br/>
|
||||
<b>herjarsa</b>
|
||||
</a><br/>
|
||||
<sub>🏅 21 commits • +6K lines</sub><br/>
|
||||
<sub>Zero-latency combos, vision-bridge auto-routing,<br/>catalog context-length, resilience 429 hints</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -1117,11 +962,11 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 👥 280+ Contributors
|
||||
## 👥 Contributors
|
||||
|
||||
</div>
|
||||
|
||||
[](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
|
||||
[](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
|
||||
|
||||
### How to Contribute
|
||||
|
||||
@@ -1146,13 +991,14 @@ gh release create v3.8.2 --title "v3.8.2" --generate-notes
|
||||
|
||||
## 📊 Stars
|
||||
|
||||
<a href="https://www.star-history.com/?repos=diegosouzapw%2FOmniRoute&type=date&legend=top-left">
|
||||
<a href="https://www.star-history.com/?repos=diegosouzapw%2Fomniroute&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=diegosouzapw/OmniRoute&type=date&theme=dark&legend=top-left&sealed_token=XP_ycEjv7s31p1edvhsMOXry51OWYsUjDRWjflSG7jQKRpO9hPGg7i_EHvwhI6QtrARTMH-YGjJhi8sumRYflEJD0DPlH_MMHjizhBYCX8fbHFrHEiNvVA" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=diegosouzapw/OmniRoute&type=date&legend=top-left&sealed_token=XP_ycEjv7s31p1edvhsMOXry51OWYsUjDRWjflSG7jQKRpO9hPGg7i_EHvwhI6QtrARTMH-YGjJhi8sumRYflEJD0DPlH_MMHjizhBYCX8fbHFrHEiNvVA" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=diegosouzapw/OmniRoute&type=date&legend=top-left&sealed_token=XP_ycEjv7s31p1edvhsMOXry51OWYsUjDRWjflSG7jQKRpO9hPGg7i_EHvwhI6QtrARTMH-YGjJhi8sumRYflEJD0DPlH_MMHjizhBYCX8fbHFrHEiNvVA" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=diegosouzapw/omniroute&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=diegosouzapw/omniroute&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=diegosouzapw/omniroute&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -1177,84 +1023,31 @@ gh release create v3.8.2 --title "v3.8.2" --generate-notes
|
||||
|
||||
</div>
|
||||
|
||||
OmniRoute stands on the shoulders of giants. It started as a fork of **[9router](https://github.com/decolua/9router)** and a TypeScript port of the Go project **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — and from there, every subsystem below was inspired by an open-source project that got there first. Each one shaped a concrete piece of OmniRoute. This is our thank-you to all of them. 🙏
|
||||
Special thanks to **[9router](https://github.com/decolua/9router)** by **[decolua](https://github.com/decolua)** — the original project that inspired this fork. OmniRoute builds upon that incredible foundation with additional features, multi-modal APIs, and a full TypeScript rewrite.
|
||||
|
||||
> ⭐ star counts as of June 2026 — go give these projects a star.
|
||||
Special thanks to **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** by **[router-for-me](https://github.com/router-for-me)** — the original Go implementation that inspired this JavaScript port.
|
||||
|
||||
### 🧬 Lineage & gateway
|
||||
Special thanks to **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project whose caveman-speak compression philosophy inspired OmniRoute's standard compression mode and 30+ filler/condensation regex rules.
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ------------------------------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[9router](https://github.com/decolua/9router)** · decolua | 19.0k | The original project this fork is built on — extended here with multi-modal APIs and a full TypeScript rewrite. |
|
||||
| **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** · router-for-me | 38.8k | The Go implementation that inspired this JavaScript / TypeScript port. |
|
||||
| **[LiteLLM](https://github.com/BerriAI/litellm)** · BerriAI | 52.1k | The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing. |
|
||||
Special thanks to **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** by **[RTK AI](https://github.com/rtk-ai)** — the high-performance command-output compression project whose terminal, build, test, git, and tool-output filtering model inspired OmniRoute's RTK engine, JSON filter DSL, raw-output recovery, and stacked RTK → Caveman compression pipeline.
|
||||
|
||||
### 🗜️ Context & token compression — engines
|
||||
Special thanks to **[Troglodita](https://github.com/leninejunior/troglodita)** by **[Lenine Júnior](https://github.com/leninejunior)** — the PT-BR token compression project ("por que gastar muitos tokens quando poucos resolve?") whose Portuguese-native rules power OmniRoute's pt-BR language pack: pleonasm reduction, filler removal tuned for Brazilian Portuguese grammar, and technical abbreviations for the dev BR community.
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ----------------------------------------------------------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[Caveman](https://github.com/JuliusBrussee/caveman)** · JuliusBrussee | 78.2k | The viral "why use many token when few token do trick" project — its caveman-speak philosophy powers our standard compression mode and 30+ filler/condensation rules. |
|
||||
| **[RTK – Rust Token Killer](https://github.com/rtk-ai/rtk)** · rtk-ai | 67.3k | High-performance command-output compression — inspired our RTK engine, JSON filter DSL, raw-output recovery and the stacked RTK → Caveman pipeline. |
|
||||
| **[headroom](https://github.com/headroomlabs-ai/headroom)** · headroomlabs-ai | 54.5k | Reversible context-compression (SmartCrusher) — inspired our `headroom` engine and the `ccr` retrieve-marker pattern. |
|
||||
| **[LLMLingua](https://github.com/microsoft/LLMLingua)** · Microsoft | 6.4k | Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open `llmlingua` engine. |
|
||||
| **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** · atjsh | 28 | The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine. |
|
||||
| **[Troglodita](https://github.com/leninejunior/troglodita)** · Lenine Júnior | 16 | PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar. |
|
||||
| **[ponytail](https://github.com/DietrichGebert/ponytail)** · DietrichGebert | 68.8k | The viral "lazy senior dev" YAGNI-coder skill — inspired our **less-code** Output Style: smallest-working-change steering that cuts _generated_ code (the output-axis sibling to Caveman's terse prose). |
|
||||
Special thanks to **[headroom](https://github.com/chopratejas/headroom)** by **[chopratejas](https://github.com/chopratejas)** — the reversible context-compression project whose SmartCrusher (per-type routing, reversible block compaction, internal hash cache) directly inspired OmniRoute's `headroom` engine and the `ccr` retrieve-marker pattern.
|
||||
|
||||
### 🧩 Compact formats, token research & code-aware tooling
|
||||
Special thanks to **[TOON](https://github.com/toon-format/toon)** by **[toon-format](https://github.com/toon-format)** and **[GCF — Graph Compact Format](https://github.com/blackwell-systems/gcf)** by **[Dayna Blackwell / Blackwell Systems](https://github.com/blackwell-systems)** — the compact, schema-aware "JSON for LLMs" notations whose columnar, header-plus-rows model shaped OmniRoute's `headroom`/SmartCrusher tabular stage: a dependency-free, lossless compaction of homogeneous JSON arrays with an explicit `[N rows]` marker.
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ---------------------------------------------------------------------------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[TOON](https://github.com/toon-format/toon)** · toon-format | 24.7k | Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage. |
|
||||
| **[GCF – Graph Compact Format](https://github.com/blackwell-systems/gcf)** · Blackwell Systems | 14 | First inspired our tabular compaction stage; now its zero-dependency, lossless generic-profile encoder is **vendored directly** as the Headroom codec (MIT, SPDX-marked), current with GCF spec v3.2. |
|
||||
| **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** · ooples | 421 | Brotli/SQLite cache + per-session context-delta — inspired our `session-dedup` engine. |
|
||||
| **[token-savior](https://github.com/Mibayy/token-savior)** · Mibayy | 1.0k | Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction. |
|
||||
| **[token-saver](https://github.com/ppgranger/token-saver)** · ppgranger | 110 | Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip. |
|
||||
| **[token-optimizer](https://github.com/alexgreensh/token-optimizer)** · alexgreensh | 1.5k | "Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking. |
|
||||
| **[TokenMizer](https://github.com/Shweta-Mishra-ai/tokenmizer)** · Shweta-Mishra-ai | 2 | A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design. |
|
||||
| **[OmniCompress](https://github.com/jessefreitas/OmniCompress)** · jessefreitas | 2 | Rust columnar-JSON + content-addressed retrieve + cross-message dedup — validated our `headroom`/`ccr`/`session-dedup` engine design and the cache-stable "compressed form is position-independent" invariant. |
|
||||
| **[mcp-compressor](https://github.com/atlassian-labs/mcp-compressor)** · Atlassian Labs | 89 | MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction. |
|
||||
| **[RepoMapper](https://github.com/pdavis68/RepoMapper)** · pdavis68 | 181 | Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration. |
|
||||
| **[quiet-shell-mcp](https://github.com/mrsimpson/quiet-shell-mcp)** · mrsimpson | 4 | Declarative shell-output reduction over MCP — validated our declarative bash-output compaction. |
|
||||
| **[ts-morph](https://github.com/dsherret/ts-morph)** · David Sherret | 6.1k | TypeScript Compiler API toolkit — inspired our parser-based comment removal that preserves string, template and regex literals. |
|
||||
Special thanks to **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** by **[ooples](https://github.com/ooples)** — the Brotli/SQLite cache + per-session context-delta project whose content-addressed delta model inspired OmniRoute's `session-dedup` engine (cross-turn block deduplication with reversible references).
|
||||
|
||||
### 🧠 Memory & RAG
|
||||
Special thanks to **[token-savior](https://github.com/Mibayy/token-savior)** by **[Mibayy](https://github.com/Mibayy)** — the Bash-output compaction + MCP-profiles project whose failure-aware bail-out and tool-profile model inspired OmniRoute's compression bail-out discipline and MCP tool-manifest cardinality reduction.
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ------------------------------------------------------------------ | ----: | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[Mem0](https://github.com/mem0ai/mem0)** · mem0ai | 59.8k | Universal memory layer — its proxy-as-write/read-boundary model shaped our memory architecture. |
|
||||
| **[Letta (MemGPT)](https://github.com/letta-ai/letta)** · letta-ai | 23.6k | Stateful agents with tiered memory — inspired our Context Control & Recovery (CCR) tiered model. |
|
||||
| **[WFGY](https://github.com/onestardao/WFGY)** · onestardao | 1.8k | The ProblemMap taxonomy of 16 recurring RAG/LLM failure modes — the shared vocabulary in our troubleshooting guide. |
|
||||
Special thanks to **[LLMLingua](https://github.com/microsoft/LLMLingua)** by **[Microsoft](https://github.com/microsoft)** — the prompt-compression research (LLMLingua / LLMLingua-2) whose token-level semantic pruning inspired OmniRoute's async `llmlingua` engine (prose-only, code-safe, fail-open), together with the JS/ONNX port **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** by **[atjsh](https://github.com/atjsh)** (MobileBERT / XLM-RoBERTa ONNX models) as its intended worker-thread backend.
|
||||
|
||||
### 🛰️ Traffic inspection, MITM & transparent proxy
|
||||
Special thanks to **[ts-morph](https://github.com/dsherret/ts-morph)** by **[David Sherret](https://github.com/dsherret)** — the TypeScript Compiler API toolkit whose AST approach inspired OmniRoute's parser-based code-comment removal, which correctly preserves string, template, and regex literals where naïve regex stripping corrupts them.
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| --------------------------------------------------------------------------------- | ---: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[llm-interceptor](https://github.com/chouzz/llm-interceptor)** · chouzz | 48 | MITM interception/analysis of coding-assistant ↔ LLM traffic — our Traffic Inspector ports its SSE merge, conversation normalization, host passthrough and secret masking (MIT). |
|
||||
| **[ProxyBridge](https://github.com/InterceptSuite/ProxyBridge)** · InterceptSuite | 5.3k | Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, `/proc` process attribution and TPROXY capture. |
|
||||
Special thanks to **[React Flow / xyflow](https://github.com/xyflow/xyflow)** by **[xyflow](https://github.com/xyflow)** — the node-based graph library that powers OmniRoute's real-time **Compression Studio** and **Combo/Routing Studio** dashboards.
|
||||
|
||||
### 📚 Model data, observability & UI
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| -------------------------------------------------------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[models.dev](https://github.com/anomalyco/models.dev)** · SST / OpenCode | 5.6k | Open database of AI model specs, pricing and capabilities — synced natively into our model catalog. |
|
||||
| **[React Flow / xyflow](https://github.com/xyflow/xyflow)** · xyflow | 37.4k | The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio. |
|
||||
| **[LangGraph](https://github.com/langchain-ai/langgraph)** · LangChain | 36.1k | LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view. |
|
||||
| **[Langfuse](https://github.com/langfuse/langfuse)** · Langfuse | 30.1k | Its trace → span → generation observability model shaped our Compression Studio waterfall. |
|
||||
| **[Kiali](https://github.com/kiali/kiali)** · Kiali | 3.6k | Istio service-mesh observability — inspired our circuit-breaker badges and error-edge visuals in the Routing/Combo Studio. |
|
||||
| **[lobe-icons](https://github.com/lobehub/lobe-icons)** · LobeHub | 2.2k | AI/LLM brand logos that render the provider icons across our dashboard. |
|
||||
|
||||
### 🛡️ Security
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ------------------------------------------------------------------------------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **[awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)** · tldrsec | 708 | A curated list of secure-by-default libraries that guides our security choices (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink). |
|
||||
|
||||
### 🧭 Complementary tools
|
||||
|
||||
| Project | How it composes with OmniRoute |
|
||||
| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[CodeWebChat](https://github.com/robertpiosik/CodeWebChat)** · robertpiosik | Editor-side companion — VS Code + browser extension that autofills 15+ chatbot web UIs with editor context. Owns the free-web-UI rail alongside OmniRoute's API rail; can point its API mode at OmniRoute. |
|
||||
Special thanks to **[LangGraph](https://github.com/langchain-ai/langgraph)** by **[LangChain](https://github.com/langchain-ai)** — the agent-graph framework whose LangGraph Studio live workflow-graph visualization inspired OmniRoute's Compression and Combo Studios: watching compression engines and combo fallbacks cascade in real time.
|
||||
|
||||
## ❤️ Support
|
||||
|
||||
@@ -1274,7 +1067,7 @@ MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
**[⬆ Back to top](#-omniroute)** · Built with ❤️ for the open-source AI community.
|
||||
|
||||
<sub>OmniRoute v3.8.43 · Node ≥22.0.0 · MIT License · <a href="https://omniroute.online">omniroute.online</a></sub>
|
||||
<sub>OmniRoute v3.8.24 · Node ≥22.0.0 · MIT License · <a href="https://omniroute.online">omniroute.online</a></sub>
|
||||
|
||||
</div>
|
||||
<!-- GitHub Discussions enabled for community Q&A -->
|
||||
|
||||
16
SECURITY.md
16
SECURITY.md
@@ -113,14 +113,14 @@ PII_REDACTION_ENABLED=true
|
||||
|
||||
### 🌐 Network Security
|
||||
|
||||
| Feature | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------------ |
|
||||
| **CORS** | Explicit cross-origin allowlist (`CORS_ALLOWED_ORIGINS`; legacy `CORS_ORIGIN`) |
|
||||
| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard |
|
||||
| **Rate Limiting** | Per-provider rate limits with automatic backoff |
|
||||
| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s |
|
||||
| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection |
|
||||
| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures |
|
||||
| Feature | Description |
|
||||
| ------------------------ | ---------------------------------------------------------------- |
|
||||
| **CORS** | Configurable origin control (`CORS_ORIGIN` env var, default `*`) |
|
||||
| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard |
|
||||
| **Rate Limiting** | Per-provider rate limits with automatic backoff |
|
||||
| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s |
|
||||
| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection |
|
||||
| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures |
|
||||
|
||||
### 🔌 Resilience & Availability
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# bin/_ops-common.sh — shared helpers for the OmniRoute ops runbook scripts.
|
||||
#
|
||||
# Sourced (not executed) by rollback.sh / snapshot-data.sh / restore-data.sh /
|
||||
# restore-policies.sh / cold-start-bench.sh — the self-hoster incident-recovery
|
||||
# and cold-start ops tooling. Each script documents its own contract via --help.
|
||||
#
|
||||
# Path resolution mirrors the app (src/lib/db/core.ts): the SQLite store is
|
||||
# $DATA_DIR/storage.sqlite and managed backups go to $DATA_DIR/db_backups
|
||||
# (overridable via DB_BACKUPS_DIR), so snapshots created here are interchangeable
|
||||
# with the ones the server writes on migrations.
|
||||
|
||||
# Recompute the data-dir-derived paths. Called once on source, and again by
|
||||
# scripts that accept a --data-dir override.
|
||||
ops_set_data_dir() {
|
||||
OMNIROUTE_DATA_DIR="$1"
|
||||
OMNIROUTE_SQLITE="${OMNIROUTE_DATA_DIR}/storage.sqlite"
|
||||
OMNIROUTE_BACKUPS_DIR="${DB_BACKUPS_DIR:-${OMNIROUTE_DATA_DIR}/db_backups}"
|
||||
}
|
||||
ops_set_data_dir "${DATA_DIR:-$HOME/.omniroute}"
|
||||
|
||||
ops_log() { printf '[%s] %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2; }
|
||||
ops_die() {
|
||||
printf '[%s] ERROR: %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ops_require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || ops_die "required command not found: $1"
|
||||
}
|
||||
|
||||
# ops_confirm "<prompt>" — return 0 to proceed. Honors ASSUME_YES=1 (set by the
|
||||
# --yes flag) and REFUSES a destructive action on a non-interactive stdin unless
|
||||
# ASSUME_YES is set, so an unattended/CI invocation can never silently destroy data.
|
||||
ops_confirm() {
|
||||
local prompt="$1" reply
|
||||
if [ "${ASSUME_YES:-0}" = "1" ]; then return 0; fi
|
||||
if [ ! -t 0 ]; then
|
||||
ops_die "refusing a destructive action without a TTY; pass --yes to proceed non-interactively"
|
||||
fi
|
||||
read -r -p "$prompt [y/N] " reply
|
||||
case "$reply" in
|
||||
[yY] | [yY][eE][sS]) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ops_find_snapshot <id> — resolve a snapshot identifier (a snapshot dir name,
|
||||
# a bare timestamp/sha, or an explicit path) to a directory containing
|
||||
# storage.sqlite. Echoes the resolved dir or dies.
|
||||
ops_find_snapshot() {
|
||||
local id="$1" cand
|
||||
[ -n "$id" ] || ops_die "snapshot id required (a timestamp/sha, dir name, or path)"
|
||||
for cand in \
|
||||
"$id" \
|
||||
"$id/" \
|
||||
"$OMNIROUTE_BACKUPS_DIR/$id" \
|
||||
"$OMNIROUTE_BACKUPS_DIR/snapshot_$id"; do
|
||||
if [ -f "${cand%/}/storage.sqlite" ]; then
|
||||
printf '%s\n' "${cand%/}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fall back to a prefix match against snapshot_* dirs (e.g. a short sha/date).
|
||||
if [ -d "$OMNIROUTE_BACKUPS_DIR" ]; then
|
||||
for cand in "$OMNIROUTE_BACKUPS_DIR"/snapshot_*"$id"*; do
|
||||
[ -f "$cand/storage.sqlite" ] && { printf '%s\n' "$cand"; return 0; }
|
||||
done
|
||||
fi
|
||||
ops_die "no snapshot matching '$id' under $OMNIROUTE_BACKUPS_DIR (run bin/snapshot-data.sh first)"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDataDir } from "./data-dir.mjs";
|
||||
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";
|
||||
import { resolveActiveContext } from "./contexts.mjs";
|
||||
|
||||
export const RETRY_DEFAULTS = Object.freeze({
|
||||
maxAttempts: 3,
|
||||
@@ -26,12 +28,14 @@ export function getBaseUrl(opts = {}) {
|
||||
const envUrl = process.env.OMNIROUTE_BASE_URL;
|
||||
if (envUrl) return stripTrailingSlash(envUrl);
|
||||
|
||||
// Resolve from the active context (canonical store + legacy profile fallback).
|
||||
// This is what makes "remote mode" work: `omniroute contexts use <remote>`
|
||||
// routes every command at the remote server's baseUrl.
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
if (ctx?.baseUrl) return stripTrailingSlash(ctx.baseUrl);
|
||||
const configPath = join(resolveDataDir(), "config.json");
|
||||
if (existsSync(configPath)) {
|
||||
const cfg = JSON.parse(readFileSync(configPath, "utf8"));
|
||||
const profile = cfg.activeProfile && cfg.profiles?.[cfg.activeProfile];
|
||||
if (profile?.baseUrl) return stripTrailingSlash(profile.baseUrl);
|
||||
if (cfg.baseUrl) return stripTrailingSlash(cfg.baseUrl);
|
||||
}
|
||||
} catch {
|
||||
// Config read failures are not fatal — fall through to default.
|
||||
}
|
||||
@@ -52,40 +56,15 @@ function resolveUrl(path, opts) {
|
||||
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export async function buildHeaders(opts) {
|
||||
async function buildHeaders(opts) {
|
||||
const headers = new Headers(opts.headers || {});
|
||||
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||
if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
// Auth precedence: explicit key → active-context credential → ambient env key.
|
||||
//
|
||||
// The active context's scoped token MUST win over the ambient OMNIROUTE_API_KEY:
|
||||
// `omniroute connect <remote>` saves the context's token, but users keep
|
||||
// OMNIROUTE_API_KEY in their shell. The global `--api-key` option is bound to
|
||||
// that env var (.env("OMNIROUTE_API_KEY")), so commands that spread
|
||||
// `optsWithGlobals()` into apiFetch carry opts.apiKey === the env value. If that
|
||||
// echoed value outranked the context, every remote management command would send
|
||||
// the local inference key and fail with "Invalid management token" — defeating
|
||||
// remote mode. So an opts.apiKey that merely mirrors the ambient env var is
|
||||
// treated as ambient (a fallback), NOT as an explicit override; only a DISTINCT
|
||||
// key — a real `--api-key <x>` flag or a command-supplied token like
|
||||
// `connect --key` — counts as explicit and wins. Within a context the scoped
|
||||
// accessToken wins over the legacy apiKey.
|
||||
const ambientKey = process.env.OMNIROUTE_API_KEY || null;
|
||||
const explicitKey = opts.apiKey && opts.apiKey !== ambientKey ? opts.apiKey : null;
|
||||
let auth = explicitKey;
|
||||
if (!auth) {
|
||||
try {
|
||||
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.
|
||||
}
|
||||
}
|
||||
if (!auth) auth = opts.apiKey || ambientKey || null;
|
||||
if (auth && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${auth}`);
|
||||
const apiKey = opts.apiKey ?? process.env.OMNIROUTE_API_KEY;
|
||||
if (apiKey && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${apiKey}`);
|
||||
}
|
||||
// Inject machine-id derived CLI token; env var override for testing.
|
||||
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
|
||||
|
||||
@@ -15,11 +15,7 @@ function readCache() {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(cachePath(), "utf8"));
|
||||
if (raw && typeof raw.ts === "number" && Date.now() - raw.ts < CACHE_TTL_MS) return raw;
|
||||
} catch (err) {
|
||||
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
|
||||
console.error("[omniroute completion] readCache failed:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,20 +41,12 @@ async function refreshCache(opts = {}) {
|
||||
const j = await mr.value.json();
|
||||
models = (Array.isArray(j) ? j : j.data || []).map((m) => m.id).filter(Boolean);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
|
||||
console.error("[omniroute completion] refreshCache failed:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const data = { combos, providers, models, ts: Date.now() };
|
||||
try {
|
||||
mkdirSync(dirname(cachePath()), { recursive: true });
|
||||
writeFileSync(cachePath(), JSON.stringify(data));
|
||||
} catch (err) {
|
||||
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
|
||||
console.error("[omniroute completion] writeCache failed:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ async function restCompressionStatus() {
|
||||
const combosBody = combosRes.ok ? await combosRes.json() : { combos: [] };
|
||||
const analytics = analyticsRes && analyticsRes.ok ? await analyticsRes.json() : null;
|
||||
return {
|
||||
strategy: settings.defaultMode || "standard",
|
||||
engine: settings.engine ?? null,
|
||||
settings,
|
||||
combos: combosBody.combos ?? combosBody,
|
||||
analytics,
|
||||
@@ -33,10 +33,7 @@ async function restCompressionStatus() {
|
||||
|
||||
async function restCompressionConfigure(config) {
|
||||
const body = { ...config };
|
||||
if (body.strategy) {
|
||||
body.defaultMode = body.strategy === "caveman" ? "standard" : normalizeEngine(body.strategy);
|
||||
delete body.strategy;
|
||||
}
|
||||
if (body.engine) body.engine = normalizeEngine(body.engine);
|
||||
const res = await apiFetch("/api/settings/compression", { method: "PUT", body });
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
@@ -46,10 +43,9 @@ async function restCompressionConfigure(config) {
|
||||
}
|
||||
|
||||
async function restSetEngine(name) {
|
||||
const normalized = normalizeEngine(name);
|
||||
const res = await apiFetch("/api/settings/compression", {
|
||||
method: "PUT",
|
||||
body: { defaultMode: normalized === "caveman" ? "standard" : normalized },
|
||||
body: { engine: normalizeEngine(name) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
@@ -107,11 +103,7 @@ export async function runCompressionStatus(opts, cmd) {
|
||||
|
||||
export async function runCompressionConfigure(opts, cmd) {
|
||||
const config = {};
|
||||
// #6571 — both the MCP tool schema (compressionConfigureInput) and
|
||||
// handleCompressionConfigure expect `strategy`, not `engine`; a non-strict
|
||||
// MCP schema silently strips an unrecognized `engine` key on the primary
|
||||
// (MCP-mounted) path, so this must be `strategy` on both paths.
|
||||
if (opts.engine) config.strategy = normalizeEngine(opts.engine);
|
||||
if (opts.engine) config.engine = opts.engine;
|
||||
if (opts.cavemanAggressiveness !== undefined)
|
||||
config.caveman = { aggressiveness: opts.cavemanAggressiveness };
|
||||
if (opts.rtkBudget !== undefined) config.rtk = { tokenBudget: opts.rtkBudget };
|
||||
@@ -171,7 +163,7 @@ export function registerCompression(program) {
|
||||
engine.command("set <name>").action(runCompressionEngineSet);
|
||||
engine.command("get").action(async (opts, cmd) => {
|
||||
const data = await mcpCall("omniroute_compression_status", {}, restCompressionStatus);
|
||||
process.stdout.write(`${data.strategy ?? "(default)"}\n`);
|
||||
process.stdout.write(`${data.engine ?? "(default)"}\n`);
|
||||
});
|
||||
|
||||
const combos = cmp.command("combos").description(t("compression.combos.description"));
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute configure <cli>` — interactive provider+model picker that writes a
|
||||
* local CLI config pointed at the ACTIVE OmniRoute context (local or remote).
|
||||
*
|
||||
* The model catalog comes from the active context's GET /v1/models, so when you
|
||||
* are in remote mode (`omniroute connect ...`) you pick from the remote server's
|
||||
* live models and the profile is written on THIS machine.
|
||||
*
|
||||
* v1 targets the Codex CLI (writes ~/.codex/<name>.config.toml). The credential
|
||||
* is referenced by env var (OMNIROUTE_API_KEY) — never written to disk.
|
||||
*/
|
||||
|
||||
const SUPPORTED = ["codex"];
|
||||
|
||||
/** Derive a short, filesystem-safe profile name from a model id. */
|
||||
export function profileNameFromModel(modelId) {
|
||||
const afterProvider = String(modelId).includes("/")
|
||||
? String(modelId).split("/").slice(1).join("/")
|
||||
: String(modelId);
|
||||
return afterProvider.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "model";
|
||||
}
|
||||
|
||||
/** Provider id for a catalog entry: explicit owned_by, else the id prefix. */
|
||||
function providerOf(entry) {
|
||||
if (entry && typeof entry.owned_by === "string" && entry.owned_by) return entry.owned_by;
|
||||
const id = typeof entry === "string" ? entry : entry?.id || "";
|
||||
return id.includes("/") ? id.split("/")[0] : "(none)";
|
||||
}
|
||||
|
||||
function contextWindowOf(entry) {
|
||||
for (const c of [entry?.context_length, entry?.max_context_window_tokens]) {
|
||||
if (typeof c === "number" && Number.isFinite(c) && c > 0) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchModels(globalOpts) {
|
||||
const res = await apiFetch("/v1/models", { ...globalOpts, acceptNotOk: true });
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const b = await res.json();
|
||||
msg = b?.error?.message || b?.error || msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(`Could not fetch models: ${msg}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data || body.models || [];
|
||||
return list.filter((m) => (typeof m === "string" ? m : m?.id));
|
||||
}
|
||||
|
||||
function buildCodexProfile(modelId, ctx) {
|
||||
const lines = [
|
||||
`# codex --profile ${profileNameFromModel(modelId)}`,
|
||||
`# ${modelId} — generated by 'omniroute configure codex'`,
|
||||
`model = "${modelId}"`,
|
||||
`model_provider = "omniroute"`,
|
||||
];
|
||||
if (ctx && ctx > 0) {
|
||||
const compact = Math.floor(ctx * 0.85);
|
||||
lines.push(`model_context_window = ${ctx}`);
|
||||
lines.push(`model_auto_compact_token_limit = ${compact}`);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
async function configureCodex(modelId, ctxWindow, opts) {
|
||||
const codexHome = opts.codexHome || path.join(os.homedir(), ".codex");
|
||||
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
|
||||
const profile = opts.name || profileNameFromModel(modelId);
|
||||
const filePath = path.join(codexHome, `${profile}.config.toml`);
|
||||
if (existsSync(filePath)) {
|
||||
copyFileSync(filePath, `${filePath}.bak`);
|
||||
}
|
||||
writeFileSync(filePath, buildCodexProfile(modelId, ctxWindow), "utf8");
|
||||
printSuccess(`Wrote ${filePath}`);
|
||||
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).");
|
||||
}
|
||||
|
||||
export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
const target = String(cli || "").toLowerCase();
|
||||
if (!SUPPORTED.includes(target)) {
|
||||
printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`);
|
||||
return 2;
|
||||
}
|
||||
const globalOpts = cmd ? cmd.optsWithGlobals() : {};
|
||||
|
||||
let models;
|
||||
try {
|
||||
models = await fetchModels(globalOpts);
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
return 1;
|
||||
}
|
||||
if (!models.length) {
|
||||
printError("The server returned no models.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Resolve model: explicit flags or interactive pick.
|
||||
let chosenId = opts.model;
|
||||
if (chosenId && opts.provider && !chosenId.includes("/")) {
|
||||
chosenId = `${opts.provider}/${chosenId}`;
|
||||
}
|
||||
|
||||
if (!chosenId) {
|
||||
const ids = models.map((m) => (typeof m === "string" ? m : m.id));
|
||||
const providers = [...new Set(models.map(providerOf))].sort();
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
printHeading("Configure Codex CLI");
|
||||
let providerList = providers;
|
||||
if (opts.provider) {
|
||||
providerList = providers.filter((p) => p === opts.provider);
|
||||
} else {
|
||||
printInfo(`Providers: ${providers.join(", ")}`);
|
||||
const p = await prompt.ask("Provider");
|
||||
if (p) providerList = providers.filter((x) => x === p);
|
||||
}
|
||||
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
|
||||
const candidates = inProvider.length ? inProvider : ids;
|
||||
printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`);
|
||||
chosenId = await prompt.ask("Model id");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!chosenId) {
|
||||
printError("No model selected.");
|
||||
return 2;
|
||||
}
|
||||
const entry = byId(models, chosenId);
|
||||
if (!entry) {
|
||||
printError(`Model '${chosenId}' is not in the catalog.`);
|
||||
return 2;
|
||||
}
|
||||
const ctxWindow = contextWindowOf(entry);
|
||||
|
||||
if (target === "codex") {
|
||||
await configureCodex(chosenId, ctxWindow, opts);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function byId(models, id) {
|
||||
for (const m of models) {
|
||||
const mid = typeof m === "string" ? m : m.id;
|
||||
if (mid === id) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerConfigure(program) {
|
||||
program
|
||||
.command("configure <cli>")
|
||||
.description(
|
||||
t("configure.description") ||
|
||||
"Pick a provider+model from the active server and write a local CLI config (v1: codex)"
|
||||
)
|
||||
.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)")
|
||||
.action(async (cli, opts, cmd) => {
|
||||
const code = await runConfigureCommand(cli, opts, cmd);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { loadContexts, saveContexts } from "../contexts.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute connect <host>` — remote mode.
|
||||
*
|
||||
* Logs into a remote OmniRoute server and saves the result as the active context
|
||||
* so every subsequent command targets that server. Two flows:
|
||||
* - password: prompts for the management password → POST /api/cli/connect →
|
||||
* server mints a scoped access token (default scope: admin).
|
||||
* - token: `--key <oma_...>` validates via GET /api/cli/whoami and saves it.
|
||||
*/
|
||||
|
||||
/** Normalize a host/URL into a server root baseUrl (no trailing path). */
|
||||
export function normalizeBaseUrl(host, port) {
|
||||
let value = String(host || "").trim();
|
||||
if (!value) return "";
|
||||
const hadScheme = /^https?:\/\//i.test(value);
|
||||
if (!hadScheme) value = `http://${value}`;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
// Only apply the default port to a bare host; a full URL is taken as-is.
|
||||
if (!hadScheme && !u.port && port) u.port = String(port);
|
||||
return u.origin;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive a clean context name from a host (strip scheme/port). */
|
||||
export function hostLabel(host) {
|
||||
let value = String(host || "").trim().replace(/^https?:\/\//i, "");
|
||||
value = value.split("/")[0].split(":")[0];
|
||||
return value || "remote";
|
||||
}
|
||||
|
||||
async function readErrorMessage(res) {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.error?.message || body?.error || `HTTP ${res.status}`;
|
||||
} catch {
|
||||
return `HTTP ${res.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runConnectCommand(host, opts = {}) {
|
||||
const baseUrl = normalizeBaseUrl(host, opts.port || "20128");
|
||||
if (!baseUrl) {
|
||||
printError("A host is required, e.g. omniroute connect 192.168.0.15");
|
||||
return 2;
|
||||
}
|
||||
const name = opts.name || hostLabel(host);
|
||||
|
||||
let accessToken;
|
||||
let scope;
|
||||
|
||||
if (opts.key) {
|
||||
// Validate the pasted token against the remote.
|
||||
const res = await apiFetch("/api/cli/whoami", {
|
||||
baseUrl,
|
||||
apiKey: opts.key,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Token rejected by ${baseUrl}: ${await readErrorMessage(res)}`);
|
||||
return res.exitCode || 1;
|
||||
}
|
||||
const body = await res.json();
|
||||
accessToken = opts.key;
|
||||
scope = body.scope || "unknown";
|
||||
} else {
|
||||
const prompt = createPrompt();
|
||||
let password;
|
||||
try {
|
||||
password = await prompt.askSecret(`Management password for ${baseUrl}`);
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
if (!password) {
|
||||
printError("Password is required (or use --key <token>).");
|
||||
return 2;
|
||||
}
|
||||
const res = await apiFetch("/api/cli/connect", {
|
||||
baseUrl,
|
||||
method: "POST",
|
||||
body: { password, name, scope: opts.scope },
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Connect failed (${res.status}): ${await readErrorMessage(res)}`);
|
||||
return res.exitCode || 1;
|
||||
}
|
||||
const body = await res.json();
|
||||
accessToken = body.token;
|
||||
scope = body.scope;
|
||||
}
|
||||
|
||||
const cfg = loadContexts();
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl,
|
||||
accessToken,
|
||||
scope,
|
||||
description: `Remote OmniRoute (${host})`,
|
||||
};
|
||||
cfg.currentContext = name;
|
||||
saveContexts(cfg);
|
||||
|
||||
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
|
||||
printInfo("All commands now target this server.");
|
||||
printInfo("Switch back to local with: omniroute contexts use default");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerConnect(program) {
|
||||
program
|
||||
.command("connect <host>")
|
||||
.description(
|
||||
t("connect.description") || "Connect to a remote OmniRoute server and enter remote mode"
|
||||
)
|
||||
.option("--port <port>", "Server port when the host has none", "20128")
|
||||
.option("--key <token>", "Use a pre-generated scoped access token (skips the password prompt)")
|
||||
.option("--name <name>", "Context name to save (default: derived from host)")
|
||||
.option("--scope <scope>", "Requested scope for the password flow (read|write|admin)")
|
||||
.action(async (host, opts) => {
|
||||
const code = await runConnectCommand(host, opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,8 @@
|
||||
import { t } from "../i18n.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs";
|
||||
import { loadContexts, saveContexts, configPath } 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";
|
||||
return "✗";
|
||||
}
|
||||
|
||||
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`);
|
||||
return false;
|
||||
}
|
||||
async function confirm(msg) {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((r) => rl.question(`${msg} [y/N] `, r));
|
||||
@@ -34,7 +19,6 @@ function maskKey(k) {
|
||||
export function registerContexts(program) {
|
||||
const ctx = program
|
||||
.command("contexts")
|
||||
.alias("context") // singular alias — docs/connect output historically said `context current`
|
||||
.description(t("config.contexts.description") || "Manage server contexts/profiles");
|
||||
|
||||
ctx
|
||||
@@ -47,8 +31,7 @@ export function registerContexts(program) {
|
||||
active: name === (cfg.currentContext || "default") ? "●" : "",
|
||||
name,
|
||||
baseUrl: c.baseUrl || "",
|
||||
auth: authLabel(c),
|
||||
scope: c.scope || "",
|
||||
auth: c.apiKey ? "✓" : "✗",
|
||||
description: c.description || "",
|
||||
}));
|
||||
emit(rows, globalOpts, [
|
||||
@@ -56,7 +39,6 @@ export function registerContexts(program) {
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "baseUrl", header: "Base URL" },
|
||||
{ key: "auth", header: "Auth" },
|
||||
{ key: "scope", header: "Scope" },
|
||||
{ key: "description", header: "Description" },
|
||||
]);
|
||||
});
|
||||
@@ -65,11 +47,8 @@ export function registerContexts(program) {
|
||||
.command("add <name>")
|
||||
.description("Add a new context")
|
||||
.requiredOption("--url <u>", "Base URL")
|
||||
.option("--api-key <k>", "Legacy inference API key")
|
||||
.option("--api-key <k>", "API key")
|
||||
.option("--api-key-stdin", "Read API key from stdin")
|
||||
.option("--access-token <t>", "Scoped CLI access token (preferred over --api-key)")
|
||||
.option("--access-token-stdin", "Read access token from stdin")
|
||||
.option("--scope <s>", "Token scope hint for display (read|write|admin)")
|
||||
.option("--description <d>", "Context description")
|
||||
.action(async (name, opts) => {
|
||||
const cfg = loadContexts();
|
||||
@@ -78,20 +57,15 @@ export function registerContexts(program) {
|
||||
process.exit(2);
|
||||
}
|
||||
let apiKey = opts.apiKey || null;
|
||||
let accessToken = opts.accessToken || null;
|
||||
if (opts.apiKeyStdin || opts.accessTokenStdin) {
|
||||
if (opts.apiKeyStdin) {
|
||||
const chunks = [];
|
||||
for await (const c of process.stdin) chunks.push(c);
|
||||
const value = chunks.join("").trim() || null;
|
||||
if (opts.accessTokenStdin) accessToken = value;
|
||||
else apiKey = value;
|
||||
apiKey = chunks.join("").trim() || null;
|
||||
}
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl: opts.url,
|
||||
accessToken: accessToken || undefined,
|
||||
apiKey,
|
||||
scope: opts.scope || undefined,
|
||||
description: opts.description || undefined,
|
||||
};
|
||||
saveContexts(cfg);
|
||||
@@ -114,27 +88,10 @@ export function registerContexts(program) {
|
||||
|
||||
ctx
|
||||
.command("current")
|
||||
.description("Show the active context (server, auth, scope)")
|
||||
.option("--name-only", "Print just the context name (legacy behavior)")
|
||||
.action((opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
.description("Show current active context name")
|
||||
.action(() => {
|
||||
const cfg = loadContexts();
|
||||
const name = cfg.currentContext || cfg.activeProfile || "default";
|
||||
if (opts.nameOnly) {
|
||||
process.stdout.write(`${name}\n`);
|
||||
return;
|
||||
}
|
||||
const c = resolveActiveContext(name);
|
||||
emit(
|
||||
{
|
||||
name,
|
||||
baseUrl: c.baseUrl || "",
|
||||
auth: authLabel(c),
|
||||
scope: c.scope || "",
|
||||
description: c.description || "",
|
||||
},
|
||||
globalOpts
|
||||
);
|
||||
process.stdout.write(`${cfg.currentContext || "default"}\n`);
|
||||
});
|
||||
|
||||
ctx
|
||||
@@ -151,9 +108,7 @@ export function registerContexts(program) {
|
||||
const display = {
|
||||
name,
|
||||
baseUrl: c.baseUrl,
|
||||
accessToken: maskKey(c.accessToken),
|
||||
apiKey: maskKey(c.apiKey),
|
||||
scope: c.scope,
|
||||
description: c.description,
|
||||
};
|
||||
emit(display, globalOpts);
|
||||
@@ -217,7 +172,6 @@ export function registerContexts(program) {
|
||||
if (opts.noSecrets) {
|
||||
for (const c of Object.values(out.contexts || {})) {
|
||||
c.apiKey = null;
|
||||
delete c.accessToken;
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(out, null, 2);
|
||||
@@ -255,9 +209,7 @@ export function registerContexts(program) {
|
||||
const c = raw && typeof raw === "object" ? /** @type {Record<string,unknown>} */ (raw) : {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl: typeof c.baseUrl === "string" ? c.baseUrl : "http://localhost:20128",
|
||||
accessToken: typeof c.accessToken === "string" ? c.accessToken : undefined,
|
||||
apiKey: typeof c.apiKey === "string" ? c.apiKey : null,
|
||||
scope: typeof c.scope === "string" ? c.scope : undefined,
|
||||
description: typeof c.description === "string" ? c.description : undefined,
|
||||
};
|
||||
count++;
|
||||
|
||||
@@ -3,7 +3,7 @@ import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createDecipheriv, scryptSync } from "node:crypto";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||
import { printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
@@ -380,67 +380,24 @@ function resolveLivenessUrl(options = {}) {
|
||||
return `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/api/health/degradation`;
|
||||
}
|
||||
|
||||
async function probeUrl(url) {
|
||||
try {
|
||||
const response = await fetchWithTimeout(url);
|
||||
return { ok: response.ok, status: response.status };
|
||||
} catch {
|
||||
return { ok: false, status: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkServerLiveness(options = {}) {
|
||||
const url = resolveLivenessUrl(options);
|
||||
|
||||
// First attempt: configured health endpoint (may require auth token).
|
||||
const primary = await probeUrl(url);
|
||||
if (primary.ok) {
|
||||
return ok("Server liveness", "Server health endpoint is reachable", { url, status: primary.status });
|
||||
}
|
||||
|
||||
// #6162: /api/health and /api/health/degradation require a management token.
|
||||
// When unauthenticated, fall back to probing a publicly served static asset
|
||||
// (favicon.ico) to confirm the Next.js server is alive and reachable.
|
||||
// Derive the fallback URL from the primary URL (preserving protocol/host/port)
|
||||
// so custom liveness URL configurations are honored. Fall back to defaults
|
||||
// only if the primary URL can't be parsed.
|
||||
let fallbackUrl;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.pathname = "/favicon.ico";
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
fallbackUrl = parsed.toString();
|
||||
const response = await fetchWithTimeout(url);
|
||||
if (!response.ok) {
|
||||
return warn("Server liveness", `Server responded with HTTP ${response.status}`, { url });
|
||||
}
|
||||
return ok("Server liveness", "Server health endpoint is reachable", { url });
|
||||
} catch {
|
||||
const port = parsePort(process.env.PORT || "20128", 20128);
|
||||
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);
|
||||
const host = String(options.livenessHost || process.env.OMNIROUTE_DOCTOR_HOST || "127.0.0.1")
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/.*$/, "");
|
||||
fallbackUrl = `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/favicon.ico`;
|
||||
return warn("Server liveness", "Server health endpoint is not reachable", { url });
|
||||
}
|
||||
const fallback = await probeUrl(fallbackUrl);
|
||||
|
||||
if (fallback.ok) {
|
||||
return ok(
|
||||
"Server liveness",
|
||||
`Server reachable (health endpoint returned ${primary.status}, likely requires MANAGEMENT_TOKEN)`,
|
||||
{ primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status }
|
||||
);
|
||||
}
|
||||
|
||||
return warn(
|
||||
"Server liveness",
|
||||
`Server health endpoint returned HTTP ${primary.status || "no-response"} and fallback probe failed`,
|
||||
{ primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status }
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
const rootDir =
|
||||
context.rootDir ||
|
||||
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "..");
|
||||
const dataDir = resolveDataDir();
|
||||
const dbPath = resolveStoragePath(dataDir);
|
||||
|
||||
|
||||
@@ -48,11 +48,7 @@ export async function runHealthCommand(opts = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/monitoring/health", {
|
||||
retry: false,
|
||||
timeout: 5000,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true });
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
@@ -70,22 +66,29 @@ export async function runHealthCommand(opts = {}) {
|
||||
if (health.uptime) console.log(t("health.uptime", { uptime: health.uptime }));
|
||||
if (health.version) console.log(` Version: ${health.version}`);
|
||||
|
||||
if (health.activeConnections !== undefined) {
|
||||
console.log(t("health.requests", { count: health.activeConnections }));
|
||||
if (health.requests !== undefined) {
|
||||
console.log(t("health.requests", { count: health.requests }));
|
||||
}
|
||||
|
||||
if (health.circuitBreakers && opts.verbose) {
|
||||
if (health.breakers && opts.verbose) {
|
||||
console.log("\n \x1b[1mCircuit Breakers\x1b[0m");
|
||||
const { open = 0, halfOpen = 0, closed = 0 } = health.circuitBreakers;
|
||||
console.log(` \x1b[32m● closed\x1b[0m ${closed}`);
|
||||
console.log(` \x1b[33m○ half-open\x1b[0m ${halfOpen}`);
|
||||
console.log(` \x1b[31m○ open\x1b[0m ${open}`);
|
||||
for (const [name, status] of Object.entries(health.breakers)) {
|
||||
const state =
|
||||
status.state === "closed" ? "\x1b[32m● closed\x1b[0m" : "\x1b[33m○ open\x1b[0m";
|
||||
console.log(` ${name.padEnd(20)} ${state}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.verbose && health.memoryUsage) {
|
||||
if (health.cache && opts.verbose) {
|
||||
console.log("\n \x1b[1mCache\x1b[0m");
|
||||
console.log(` Semantic hits: ${health.cache.semanticHits || 0}`);
|
||||
console.log(` Signature hits: ${health.cache.signatureHits || 0}`);
|
||||
}
|
||||
|
||||
if (opts.verbose && health.memory) {
|
||||
console.log("\n \x1b[1mMemory\x1b[0m");
|
||||
console.log(` RSS: ${health.memoryUsage.rss || "N/A"}`);
|
||||
console.log(` Heap used: ${health.memoryUsage.heapUsed || "N/A"}`);
|
||||
console.log(` RSS: ${health.memory.rss || "N/A"}`);
|
||||
console.log(` Heap used: ${health.memory.heapUsed || "N/A"}`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
@@ -97,17 +100,13 @@ export async function runHealthCommand(opts = {}) {
|
||||
|
||||
export async function runHealthComponentsCommand(opts = {}) {
|
||||
try {
|
||||
const res = await apiFetch("/api/monitoring/health", {
|
||||
retry: false,
|
||||
timeout: 5000,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true });
|
||||
if (!res.ok) {
|
||||
console.error(`HTTP ${res.status}`);
|
||||
return 1;
|
||||
}
|
||||
const health = await res.json();
|
||||
const components = health.components || health.circuitBreakers || {};
|
||||
const components = health.components || health.breakers || {};
|
||||
for (const [name, info] of Object.entries(components)) {
|
||||
const status =
|
||||
typeof info === "object" ? info.state || info.status || "unknown" : String(info);
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
/** OpenAI/Codex env keys stripped from the child so a stale OpenAI key/base-url
|
||||
* in the shell can't shadow the omniroute provider (defense-in-depth). Mirrors
|
||||
* free-claude-code's codex adapter. NOTE: this does NOT silence codex's
|
||||
* `refresh_token` log noise — that comes from a stored OpenAI session in
|
||||
* ~/.codex/auth.json, not the env; it is cosmetic and does not block requests. */
|
||||
const STRIPPED_CODEX_ENV_KEYS = [
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_ORGANIZATION",
|
||||
"CODEX_API_KEY",
|
||||
];
|
||||
|
||||
/** Placeholder so codex's `env_key` is always satisfied when the backend is open. */
|
||||
const NO_AUTH_SENTINEL = "omniroute-no-auth";
|
||||
|
||||
// On Windows the `codex` binary is an npm `.cmd` shim that `spawn` cannot resolve
|
||||
// without a shell (bare "codex" → ENOENT). Mirror the qodercli Windows fix (#6263):
|
||||
// spawn `codex.cmd` through a shell on win32, and the bare binary elsewhere.
|
||||
export function resolveCodexSpawn(platform) {
|
||||
if (platform === "win32") {
|
||||
return { command: "codex.cmd", shell: true };
|
||||
}
|
||||
return { command: "codex", shell: undefined };
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
let s = String(value);
|
||||
let end = s.length;
|
||||
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
|
||||
return end === s.length ? s : s.slice(0, end);
|
||||
}
|
||||
|
||||
/** TOML assignment for a `-c key=value` codex flag (strings get quoted). */
|
||||
function tomlAssign(key, value) {
|
||||
if (typeof value === "boolean" || typeof value === "number") return `${key}=${value}`;
|
||||
return `${key}=${JSON.stringify(String(value))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OmniRoute root base URL + auth for codex, honouring (in order):
|
||||
* explicit flags → active context (remote mode) → localhost:<port>.
|
||||
* @returns {{ baseUrl:string, authToken:string|undefined }}
|
||||
*/
|
||||
export function resolveCodexTarget(opts = {}) {
|
||||
const explicit = opts.remote ?? opts.baseUrl;
|
||||
let baseUrl;
|
||||
if (explicit) {
|
||||
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
|
||||
} else {
|
||||
let fromCtx;
|
||||
try {
|
||||
fromCtx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
baseUrl = fromCtx
|
||||
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
|
||||
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let authToken = opts.apiKey ?? opts["api-key"];
|
||||
if (!authToken) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!authToken) authToken = process.env.OMNIROUTE_API_KEY;
|
||||
return { baseUrl, authToken };
|
||||
}
|
||||
|
||||
/** Health-check an OmniRoute root URL before launching Codex. */
|
||||
async function healthCheck(baseUrl, timeoutMs = 3000) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the env for the Codex child: strip stale OpenAI/Codex creds, then set
|
||||
* OMNIROUTE_API_KEY (the provider env_key) to the resolved token or a sentinel.
|
||||
* @param {Record<string,string>} baseEnv
|
||||
* @param {string|undefined} authToken
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
export function buildCodexEnv(baseEnv, authToken) {
|
||||
const env = { ...baseEnv };
|
||||
for (const key of STRIPPED_CODEX_ENV_KEYS) delete env[key];
|
||||
env.OMNIROUTE_API_KEY = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex `-c` flags that define the `omniroute` provider inline, so launch works
|
||||
* WITHOUT a pre-existing ~/.codex/config.toml. Mirrors free-claude-code.
|
||||
* @param {string} baseUrl OmniRoute root URL (no /v1)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function buildCodexProviderArgs(baseUrl) {
|
||||
return [
|
||||
"-c",
|
||||
tomlAssign("model_provider", "omniroute"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.name", "OmniRoute"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.base_url", `${baseUrl}/v1`),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.env_key", "OMNIROUTE_API_KEY"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.wire_api", "responses"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.requires_openai_auth", false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{port?:string, remote?:string, profile?:string, apiKey?:string}} opts
|
||||
* @param {string[]} codexArgs pass-through args for the codex binary
|
||||
* @returns {Promise<number>} exit code
|
||||
*/
|
||||
export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
const { baseUrl, authToken } = resolveCodexTarget(opts);
|
||||
|
||||
if (!(await healthCheck(baseUrl))) {
|
||||
console.error(
|
||||
(
|
||||
t("launch.notRunning") ||
|
||||
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
|
||||
).replace("{port}", baseUrl)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Provider injected via -c (works without config.toml); then the profile (model),
|
||||
// then the user's pass-through args.
|
||||
const providerArgs = buildCodexProviderArgs(baseUrl);
|
||||
const profileArgs = opts.profile ? ["--profile", opts.profile] : [];
|
||||
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
|
||||
const env = buildCodexEnv(process.env, authToken);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const { command: codexLaunch, shell: shellValue } = resolveCodexSpawn(process.platform);
|
||||
const child = spawn(codexLaunch, extraArgs, {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
shell: shellValue,
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
if (err?.code === "ENOENT") {
|
||||
console.error(
|
||||
"The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex"
|
||||
);
|
||||
resolve(127);
|
||||
} else {
|
||||
console.error(String(err?.message || err));
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLaunchCodex(program) {
|
||||
program
|
||||
.command("launch-codex")
|
||||
.description(
|
||||
t("launchCodex.description") || "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option(
|
||||
"--remote <url>",
|
||||
"Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)"
|
||||
)
|
||||
.option("--profile <name>", "Codex profile to activate (passed as --profile <name>)")
|
||||
.option("-p, --p <name>", "Alias for --profile")
|
||||
.option(
|
||||
"--api-key <key>",
|
||||
"OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)"
|
||||
)
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[codexArgs...]", "arguments passed through to the codex binary")
|
||||
.action(async (codexArgs, opts) => {
|
||||
const merged = { ...opts, profile: opts.profile ?? opts.p };
|
||||
const exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
@@ -1,123 +1,53 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
let s = String(value);
|
||||
let end = s.length;
|
||||
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
|
||||
return end === s.length ? s : s.slice(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clean child env for Claude Code pointed at OmniRoute.
|
||||
*
|
||||
* Strips inherited ANTHROPIC_* (avoids a stale shell token leaking through), then
|
||||
* injects the base URL, gateway model discovery, and auto-compact window.
|
||||
*
|
||||
* Build a clean child env for Claude Code pointed at the local proxy.
|
||||
* Strips any inherited ANTHROPIC_* (avoids a stale shell token leaking through),
|
||||
* then injects the proxy base URL, gateway model discovery, and auto-compact window.
|
||||
* @param {Record<string,string>} baseEnv
|
||||
* @param {number|string} baseUrlOrPort a port (→ http://localhost:<port>) or a full base URL
|
||||
* @param {number} port
|
||||
* @param {string|undefined} authToken
|
||||
* @param {{ configDir?:string, model?:string }} [opts]
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
export function buildClaudeEnv(baseEnv, baseUrlOrPort, authToken, opts = {}) {
|
||||
export function buildClaudeEnv(baseEnv, port, authToken) {
|
||||
const env = { ...baseEnv };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("ANTHROPIC_")) delete env[key];
|
||||
}
|
||||
|
||||
// Accept a bare port (number/numeric string → localhost) or a full base URL.
|
||||
// Claude Code wants the ROOT URL (it appends /v1/messages itself) — no /v1 here.
|
||||
let baseUrl;
|
||||
if (typeof baseUrlOrPort === "number" || /^\d+$/.test(String(baseUrlOrPort))) {
|
||||
baseUrl = `http://localhost:${Number(baseUrlOrPort) || 20128}`;
|
||||
} else {
|
||||
baseUrl = stripTrailingSlash(String(baseUrlOrPort)).replace(/\/v1$/, "");
|
||||
}
|
||||
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
// Always set a token: when none is resolved, a sentinel keeps newer Claude Code
|
||||
// from stopping at its local login gate before it ever contacts OmniRoute (an
|
||||
// open backend ignores the value). Mirrors free-claude-code. ANTHROPIC_API_KEY
|
||||
// stays stripped (above) so it can't shadow the Bearer token.
|
||||
env.ANTHROPIC_AUTH_TOKEN = (authToken && String(authToken).trim()) || "omniroute-no-auth";
|
||||
env.ANTHROPIC_BASE_URL = `http://localhost:${port}`;
|
||||
if (authToken) env.ANTHROPIC_AUTH_TOKEN = authToken;
|
||||
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
|
||||
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000";
|
||||
// Profile isolation (Claude Code has no native profiles — CLAUDE_CONFIG_DIR is
|
||||
// the idiomatic mechanism: separate settings/credentials/history/cache per dir).
|
||||
if (opts.configDir) env.CLAUDE_CONFIG_DIR = opts.configDir;
|
||||
if (opts.model) env.ANTHROPIC_MODEL = opts.model;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OmniRoute base URL + auth for launch, honouring (in order):
|
||||
* explicit flags → the active context (remote mode) → localhost:<port>.
|
||||
* @param {{port?:string, remote?:string, baseUrl?:string, token?:string, apiKey?:string, context?:string}} opts
|
||||
* @returns {{ baseUrl:string, authToken:string|undefined }}
|
||||
*/
|
||||
export function resolveLaunchTarget(opts = {}) {
|
||||
const explicit = opts.remote ?? opts.baseUrl;
|
||||
let baseUrl;
|
||||
if (explicit) {
|
||||
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
|
||||
} else {
|
||||
let fromCtx;
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
fromCtx = ctx?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
baseUrl = fromCtx
|
||||
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
|
||||
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let authToken = opts.token ?? opts.apiKey ?? opts["api-key"];
|
||||
if (!authToken) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!authToken) authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.OMNIROUTE_API_KEY;
|
||||
return { baseUrl, authToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts
|
||||
* @param {{port?:string, token?:string}} opts
|
||||
* @param {string[]} claudeArgs pass-through args for the claude binary
|
||||
* @returns {Promise<number>} exit code
|
||||
*/
|
||||
export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
||||
const { baseUrl, authToken } = resolveLaunchTarget(opts);
|
||||
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
|
||||
|
||||
// Health check the (possibly remote) proxy before launching.
|
||||
// Health check the proxy before launching.
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
const res = await fetch(`http://localhost:${port}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(1500),
|
||||
});
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error(
|
||||
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
|
||||
(t("launch.notRunning") || "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.").replace(
|
||||
"{port}",
|
||||
baseUrl
|
||||
String(port)
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const configDir = opts.profile
|
||||
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
|
||||
: undefined;
|
||||
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
|
||||
const token = opts.token ?? process.env.ANTHROPIC_AUTH_TOKEN ?? undefined;
|
||||
const env = buildClaudeEnv(process.env, port, token);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
|
||||
@@ -137,14 +67,9 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
||||
export function registerLaunch(program) {
|
||||
program
|
||||
.command("launch")
|
||||
.description(
|
||||
t("launch.description") || "Launch Claude Code pointed at OmniRoute (local or remote)"
|
||||
)
|
||||
.description(t("launch.description") || "Launch Claude Code pointed at the local OmniRoute proxy")
|
||||
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
|
||||
.option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
|
||||
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
|
||||
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
|
||||
.option("--token <token>", t("launch.token") || "API key the Claude client should send (ANTHROPIC_AUTH_TOKEN)")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[claudeArgs...]", "arguments passed through to the claude binary")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user