mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-27 10:26:03 +03:00
Compare commits
49 Commits
main
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
748790a895 | ||
|
|
c98e7ff6d0 | ||
|
|
8fea5ea534 | ||
|
|
b609313f49 | ||
|
|
313ce07f6d | ||
|
|
20f4c18a30 | ||
|
|
02482b3e1e | ||
|
|
4e9d4f4eb7 | ||
|
|
56eb7f0206 | ||
|
|
25bb180419 | ||
|
|
14a3bb451b | ||
|
|
6287750164 | ||
|
|
520293ffc0 | ||
|
|
b2b17a9b89 | ||
|
|
32bc6a9736 | ||
|
|
2659f1403e | ||
|
|
d7b7a14a4b | ||
|
|
5ff2cbd5f7 | ||
|
|
d3b176d233 | ||
|
|
8752c2567e | ||
|
|
a601aaf101 | ||
|
|
0324984b75 | ||
|
|
3622532d2f | ||
|
|
ecfb054b66 | ||
|
|
312d2f5887 | ||
|
|
5e9c11006b | ||
|
|
a61b5e007a | ||
|
|
c1779990af | ||
|
|
8ef5f0e9ad | ||
|
|
a2d2712e37 | ||
|
|
9a4259078e | ||
|
|
ce1a53e5f8 | ||
|
|
e9be562bf1 | ||
|
|
57ef0caae2 | ||
|
|
88bb78c4f9 | ||
|
|
b67318f57e | ||
|
|
be2a38818e | ||
|
|
d6caf6ed39 | ||
|
|
fc3b417f40 | ||
|
|
2bdf37796e | ||
|
|
e6af1ed743 | ||
|
|
67c8a0436e | ||
|
|
5887bc3d6a | ||
|
|
b04eac3b8d | ||
|
|
bd1fd79543 | ||
|
|
def39ea292 | ||
|
|
1087d5a406 | ||
|
|
e2522e2e0a | ||
|
|
5cb7621764 |
@@ -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
|
||||
506
.env.example
506
.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
|
||||
@@ -404,7 +318,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 +331,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 +363,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,21 +376,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_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
|
||||
#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace
|
||||
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings
|
||||
|
||||
@@ -519,18 +395,6 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
#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=...
|
||||
@@ -560,13 +424,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# 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 +459,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 +469,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
|
||||
@@ -689,14 +536,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 +551,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 +594,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 +666,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 +675,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 +693,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 +738,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 +764,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 +792,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,8 +801,8 @@ 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)
|
||||
@@ -1012,18 +817,13 @@ KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
|
||||
# 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 +901,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 +950,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 +989,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.
|
||||
@@ -1394,13 +1164,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 +1200,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 +1247,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 +1261,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.
|
||||
@@ -1656,19 +1355,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 +1445,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 +1475,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.
|
||||
@@ -1850,15 +1517,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 +1562,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 +1617,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,18 +1637,9 @@ 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
|
||||
# an opencode.json with accurate limit.context values. Used by:
|
||||
@@ -2021,10 +1665,6 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# 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.
|
||||
@@ -2036,29 +1676,8 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# 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_*). Falls back to BIFROST_API_KEY when unset.
|
||||
# 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/*
|
||||
@@ -2106,30 +1725,25 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# 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
|
||||
# ── Scheduled VACUUM (Issue #4437) ────────────────────────────────────────
|
||||
# Master switch for the scheduled SQLite VACUUM job.
|
||||
# - OMNIROUTE_VACUUM_ENABLED=1 (default) → start a setInterval timer at boot
|
||||
# that runs VACUUM once every OMNIROUTE_VACUUM_INTERVAL_HOURS.
|
||||
# - 0 → disable the scheduler entirely (manual "Vacuum Now" still works).
|
||||
# Source: src/lib/db/vacuumScheduler.ts
|
||||
# OMNIROUTE_VACUUM_ENABLED=1
|
||||
|
||||
# How often to run VACUUM. Default: 24 hours. Minimum: 1. Must be an integer.
|
||||
# The actual interval is computed lazily from OMNIROUTE_VACUUM_INTERVAL_HOURS
|
||||
# at boot time, so changes here require a restart (or `omniroute vacuum restart`).
|
||||
# OMNIROUTE_VACUUM_INTERVAL_HOURS=24
|
||||
|
||||
# Window in which the first VACUUM is allowed to run (cron-like start window).
|
||||
# Default: 02:00-04:00 (local server time). Outside the window the scheduler
|
||||
# waits until the window opens. Format: HH:MM-HH:MM (24-hour).
|
||||
# OMNIROUTE_VACUUM_WINDOW=02:00-04:00
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
613
.github/workflows/ci.yml
vendored
613
.github/workflows/ci.yml
vendored
@@ -16,10 +16,6 @@ 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"
|
||||
CI_NODE_24_VERSION: "24"
|
||||
CI_NODE_26_VERSION: "26"
|
||||
@@ -33,23 +29,17 @@ jobs:
|
||||
docs: ${{ steps.classify.outputs.docs }}
|
||||
i18n: ${{ steps.classify.outputs.i18n }}
|
||||
workflow: ${{ steps.classify.outputs.workflow }}
|
||||
testsOnly: ${{ steps.classify.outputs.testsOnly }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
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: |
|
||||
# Single source of truth: scripts/quality/classify-pr-changes.mjs
|
||||
# (unit-tested). Push/dispatch always enable every lane.
|
||||
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||
{
|
||||
echo "code=true"
|
||||
@@ -60,18 +50,48 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
code=false
|
||||
docs=false
|
||||
i18n=false
|
||||
workflow=false
|
||||
|
||||
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
|
||||
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
|
||||
|
||||
while IFS= read -r file; do
|
||||
case "$file" in
|
||||
.github/workflows/*|.zizmor.yml)
|
||||
workflow=true
|
||||
code=true
|
||||
;;
|
||||
docs/*|*.md)
|
||||
docs=true
|
||||
;;
|
||||
src/i18n/*|src/i18n/messages/*|scripts/i18n/*|config/i18n.json)
|
||||
i18n=true
|
||||
code=true
|
||||
;;
|
||||
src/*|open-sse/*|bin/*|electron/*|tests/*|scripts/*|package.json|package-lock.json|tsconfig*.json|next.config.*|vitest*.config.*|playwright.config.*)
|
||||
code=true
|
||||
;;
|
||||
db/*|config/*)
|
||||
code=true
|
||||
;;
|
||||
*)
|
||||
code=true
|
||||
;;
|
||||
esac
|
||||
done < changed-files.txt
|
||||
|
||||
{
|
||||
echo "code=$code"
|
||||
echo "docs=$docs"
|
||||
echo "i18n=$i18n"
|
||||
echo "workflow=$workflow"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
# Path filter: pure docs / pure message-catalog PRs skip the code lint bag + typecheck.
|
||||
# Existence reason of this job is code regression; docs/i18n have dedicated jobs.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
|
||||
env:
|
||||
# tsx gates below (known-symbols, route-guard-membership) import modules that
|
||||
# open SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||
@@ -89,27 +109,7 @@ jobs:
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run audit:deps
|
||||
- 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 }}-
|
||||
# Single ESLint inventory (JSON) — quality-gate reuses the artifact instead of
|
||||
# a second cold full-tree pass for eslintWarnings ratchet counts.
|
||||
- name: ESLint (JSON report)
|
||||
run: npm run lint:json
|
||||
- name: Upload ESLint results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: eslint-results
|
||||
path: .artifacts/eslint-results.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
- run: npm run lint
|
||||
- run: npm run check:cycles
|
||||
- run: npm run check:route-validation:t06
|
||||
- run: npm run check:any-budget:t11
|
||||
@@ -125,43 +125,22 @@ jobs:
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:tracked-artifacts
|
||||
# WS1.7 (v3.8.49 plan): Dockerfile lint (hadolint, pinned by digest).
|
||||
# failure-threshold=error keeps the 5 pre-existing warnings (DL3008/DL3003/
|
||||
# DL3016 version pinning / WORKDIR) visible without blocking; any ERROR fails.
|
||||
- name: hadolint (Dockerfile)
|
||||
run: docker run --rm -i hadolint/hadolint@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e hadolint --failure-threshold error - < Dockerfile
|
||||
- run: npm run check:lockfile
|
||||
- run: npm run check:licenses
|
||||
# check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the
|
||||
# husky pre-commit hook; the standalone copy here was redundant (ROI dedup).
|
||||
- run: npm run typecheck:core
|
||||
# #7033: typecheck:core's curated file allowlist does not cover
|
||||
# src/app/(dashboard) TSX (and next.config.mjs sets ignoreBuildErrors:
|
||||
# true, so `next build` never type-checks it either) — orphaned
|
||||
# identifiers there (see #6625/#6909) were invisible to CI. This gate
|
||||
# runs tsc scoped to the dashboard tree against a frozen baseline of
|
||||
# pre-existing errors; only NEW errors fail it.
|
||||
- run: npm run check:dashboard-typecheck
|
||||
# typecheck:noimplicit:core dropped from this job (2026-07 optimize):
|
||||
# it was advisory (continue-on-error) and largely subsumed by the blocking
|
||||
# check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core.
|
||||
# typecheck:noimplicit:core is a forward-looking gate (noImplicitAny).
|
||||
# Run informationally for now — many pre-existing call sites still need
|
||||
# explicit annotations; track in a dedicated follow-up.
|
||||
- run: npm run typecheck:noimplicit:core
|
||||
continue-on-error: true
|
||||
|
||||
quality-gate:
|
||||
name: Quality Ratchet
|
||||
runs-on: ubuntu-latest
|
||||
# needs lint so eslint-results artifact is available (same inventory as the
|
||||
# blocking lint step). Allow lint failure so other ratchets still run.
|
||||
needs: [changes, test-coverage, lint]
|
||||
# Run even when test-coverage was SKIPPED/FAILED (e.g. a single flaky Coverage
|
||||
# Shard breaks the shard→coverage→ratchet chain). The DETERMINISTIC ratchets
|
||||
# (eslint / complexity / cognitive-complexity / duplication / codeql) do NOT need
|
||||
# the coverage artifact and MUST still run so cycle drift is measured on the
|
||||
# release PR — otherwise a flake silently skips the whole gate (incident: v3.8.36
|
||||
# release PR #4854, where the drift cascade only surfaced post-merge in #5029).
|
||||
# The coverage.* metrics degrade gracefully: the download is continue-on-error and
|
||||
# the ratchet runs with --allow-missing, so absent coverage is skipped, not failed.
|
||||
# Path filter: code-only — pure docs/i18n PRs have nothing for these ratchets to guard.
|
||||
if: ${{ !cancelled() && !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }}
|
||||
needs: test-coverage
|
||||
if: ${{ !cancelled() && needs.test-coverage.result == 'success' }}
|
||||
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
|
||||
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
|
||||
permissions:
|
||||
@@ -176,40 +155,17 @@ jobs:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- 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 }}-
|
||||
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
|
||||
# continue-on-error: o artifact pode não existir se a job test-coverage foi
|
||||
# SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem
|
||||
# erro) e o ratchet roda com --allow-missing — as métricas determinísticas
|
||||
# (eslint/complexity/cognitive) seguem BLOQUEANTES.
|
||||
- uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
# Prefer lint job's ESLint JSON (one inventory, two consumers).
|
||||
- name: Download ESLint results
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: eslint-results
|
||||
path: .artifacts/
|
||||
- run: npm run quality:collect
|
||||
# Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado).
|
||||
# Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do
|
||||
# coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados.
|
||||
# --allow-missing: pula métricas do baseline ausentes do collect (coverage.* quando
|
||||
# o artifact não veio) em vez de falhar — mantém os gates determinísticos ativos.
|
||||
- name: Ratchet check
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --summary .artifacts/quality-ratchet.md
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
|
||||
# Fase 6A.5: require-tighten — BLOQUEANTE (promovido de advisory no fim do ciclo
|
||||
# v3.8.27). Falha quando uma métrica MELHOROU sem o baseline ter sido apertado no
|
||||
# mesmo PR (força capturar ganhos permanentes). As métricas coverage.* carregam
|
||||
@@ -218,18 +174,20 @@ jobs:
|
||||
# mergeada == baseline (ver a nota _require_tighten_flip_blocking em
|
||||
# config/quality/quality-baseline.json).
|
||||
- name: Require-tighten (blocking)
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --require-tighten
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --require-tighten
|
||||
# Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo)
|
||||
# para não pesar no caminho crítico do lint.
|
||||
- name: Duplication ratchet
|
||||
run: npm run check:duplication
|
||||
# Complexity + cognitive: one ESLint walk, two independent baselines (by ruleId).
|
||||
- name: Complexity + cognitive ratchets
|
||||
run: npm run check:complexity-ratchets
|
||||
# Fase 7 INT: dead-code, type-coverage promovidos de advisory (quality-extended)
|
||||
# para BLOQUEANTES aqui. cognitive-complexity is folded into the step above.
|
||||
- name: Complexity ratchet
|
||||
run: npm run check:complexity
|
||||
# Fase 7 INT: dead-code, cognitive-complexity, type-coverage promovidos de
|
||||
# advisory (quality-extended) para BLOQUEANTES aqui. Os 3 leem seus baseline
|
||||
# de quality-baseline.json e saem 1 em regressão.
|
||||
- name: Dead-code ratchet (knip)
|
||||
run: npm run check:dead-code
|
||||
- name: Cognitive complexity ratchet (sonarjs)
|
||||
run: npm run check:cognitive-complexity
|
||||
- name: Type coverage ratchet
|
||||
run: npm run check:type-coverage
|
||||
- name: Compression budget ratchet (F2.4 / N4)
|
||||
@@ -267,14 +225,9 @@ jobs:
|
||||
quality-extended:
|
||||
name: Quality Gates (Extended)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
# Path filter: code-only (scanners/ratchets target production surface).
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true')) }}
|
||||
steps:
|
||||
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
||||
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
|
||||
# spec via `git show <base_ref>:docs/reference/openapi.yaml`; a shallow clone
|
||||
# would lack the base ref and the gate would self-skip (base-unresolved).
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -365,7 +318,7 @@ jobs:
|
||||
- name: Workflow lint (actionlint+zizmor, ratchet, blocking)
|
||||
run: npm run check:workflows -- --ratchet
|
||||
# OpenAPI breaking-change detection (oasdiff). Diffs the PR's public API
|
||||
# contract (docs/openapi.yaml) against the base branch's spec.
|
||||
# contract (docs/reference/openapi.yaml) against the base branch's spec.
|
||||
# BLOCKING ratchet (Fase 9 Onda 0): reads metrics.openapiBreaking.value and
|
||||
# exits 1 ONLY on a measured regression (count > baseline). It SKIPs (exit 0)
|
||||
# when oasdiff is absent or the base spec can't be resolved — a missing
|
||||
@@ -379,11 +332,6 @@ jobs:
|
||||
docs-sync-strict:
|
||||
name: Docs Sync (Strict)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
# Run when docs OR code change: API/route code can break doc/OpenAPI contract gates.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -403,20 +351,16 @@ jobs:
|
||||
run: npm run check:openapi-coverage
|
||||
- name: OpenAPI security-tier consistency (advisory)
|
||||
run: npm run check:openapi-security-tiers
|
||||
# One FS inventory of src/app/api for both anti-hallucination directions.
|
||||
- name: API docs refs (openapi + prose → routes)
|
||||
run: npm run check:api-docs-refs
|
||||
- name: OpenAPI spec paths resolve to real routes (anti-hallucination)
|
||||
run: npm run check:openapi-routes
|
||||
- name: Doc /api refs resolve to real routes (anti-hallucination)
|
||||
run: npm run check:docs-symbols
|
||||
- name: i18n translation drift (warn)
|
||||
run: node scripts/i18n/check-translation-drift.mjs --warn
|
||||
|
||||
docs-lint:
|
||||
name: Docs Lint (prose — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
# Prose/markdown only — skip when the PR has no doc surface.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.docs == 'true') }}
|
||||
# Advisory (warning-first): prose/markdown style must not block merges while the
|
||||
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
||||
continue-on-error: true
|
||||
@@ -443,11 +387,6 @@ jobs:
|
||||
i18n-ui-coverage:
|
||||
name: i18n UI Coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
# UI keys move with dashboard code OR message catalogs.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -459,20 +398,30 @@ jobs:
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
||||
|
||||
# D4 (plano mestre testes+CI): a matrix de ~40 jobs de <1min por idioma saturava sozinha
|
||||
# a concorrência de jobs da conta (Free = 20 slots, compartilhados entre TODOS os repos)
|
||||
# e pagava spin-up + arredondamento de billing por idioma. Um único job itera os idiomas
|
||||
# (mesmo script), com grupo de log por idioma e artifact único de resultados nomeados por
|
||||
# idioma (a matrix antiga subia 40 artifacts cujo result.txt colidia no merge-multiple).
|
||||
i18n-matrix:
|
||||
name: Build language matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
langs: ${{ steps.langs.outputs.langs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: langs
|
||||
run: |
|
||||
LANG_DIR="src/i18n/messages"
|
||||
LANGS=$(ls "$LANG_DIR"/*.json | xargs -n1 basename | sed 's/.json$//' | grep -v '^en$' | jq -R . | jq -s . | jq -c .)
|
||||
echo "langs=${LANGS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
i18n:
|
||||
name: i18n Validation (all languages)
|
||||
name: i18n Validation
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
|
||||
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
|
||||
# Message-catalog / i18n-tooling only — pure code without i18n surface skips this lane.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.i18n == 'true') }}
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
lang: ${{ fromJson(needs.i18n-matrix.outputs.langs) }}
|
||||
needs: i18n-matrix
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -480,35 +429,27 @@ jobs:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Validate all languages
|
||||
|
||||
- name: Validate ${{ matrix.lang }}
|
||||
env:
|
||||
# Pass the matrix value via env (never interpolate ${{ ... }} straight
|
||||
# into the run: script body) so the shell receives a variable, not
|
||||
# inlined text — zizmor template-injection mitigation. Named MATRIX_LANG
|
||||
# to avoid clobbering the POSIX `LANG` locale variable.
|
||||
MATRIX_LANG: ${{ matrix.lang }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
mkdir -p i18n-results
|
||||
FAIL=0
|
||||
for f in src/i18n/messages/*.json; do
|
||||
lang=$(basename "$f" .json)
|
||||
[ "$lang" = "en" ] && continue
|
||||
echo "::group::i18n $lang"
|
||||
if python3 scripts/i18n/validate_translation.py quick -l "$lang" > "i18n-results/$lang.txt" 2>&1; then
|
||||
echo "OK $lang"
|
||||
else
|
||||
FAIL=1
|
||||
echo "FAIL $lang"
|
||||
cat "i18n-results/$lang.txt"
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
exit "$FAIL"
|
||||
- name: Upload results
|
||||
python3 scripts/i18n/validate_translation.py quick -l "$MATRIX_LANG" > result.txt
|
||||
|
||||
- name: Upload result
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: i18n-results
|
||||
path: i18n-results/
|
||||
name: i18n-${{ matrix.lang }}
|
||||
path: result.txt
|
||||
|
||||
pr-test-policy:
|
||||
name: PR Test Policy
|
||||
if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft == false }}
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -519,7 +460,7 @@ jobs:
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
- name: Fetch base branch
|
||||
run: git fetch --no-tags origin "${GITHUB_BASE_REF}"
|
||||
run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1
|
||||
- name: Validate source changes include tests
|
||||
run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md
|
||||
# Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests.
|
||||
@@ -539,17 +480,9 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
# Dynamic runner: when the release captain flips the USE_VPS_RUNNER repo var to
|
||||
# 'true' (scripts/vps/release-runner-up.sh does it after the self-hosted VM is
|
||||
# online), the heavy jobs run on the dedicated 32-core VPS runners (label
|
||||
# omni-release) instead of queueing on the 20-concurrent-job hosted pool.
|
||||
# Safety: fork PRs NEVER reach the self-hosted runner — the expression falls
|
||||
# back to ubuntu-latest unless the PR head repo is this repository (push /
|
||||
# dispatch events are own-origin by definition). Any failure path (VM down,
|
||||
# var unset/false) also falls back to 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -560,21 +493,14 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
# NOTE: the webpack `.build/next/cache` actions/cache step was removed with the
|
||||
# Turbopack switch below — Turbopack does not read/write the webpack cache dir
|
||||
# (its persistent FS cache is still experimental and intentionally NOT enabled),
|
||||
# so restoring the old ~0.5 GB webpack cache would only waste download time.
|
||||
# Rolling back to webpack = revert this commit (cache step comes back with it).
|
||||
#
|
||||
# Turbopack production build (Next 16, stable): benchmarked 1.9× faster than the
|
||||
# webpack pass (9min0s vs 17min15s on a 32-core box; multi-core Rust vs webpack's
|
||||
# single-threaded compile). Standalone output smoke-validated (server boots,
|
||||
# /api/monitoring/health 200). Downstream jobs (e2e ×9, package-artifact,
|
||||
# electron-package-smoke) consume this artifact, so a green run here validates
|
||||
# the Turbopack artifact end-to-end.
|
||||
- name: Cache Next.js build cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae
|
||||
with:
|
||||
path: .build/next/cache
|
||||
key: nextjs-${{ runner.os }}-node-${{ env.CI_NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*', 'open-sse/**/*', 'db/**/*', 'next.config.mjs', 'tsconfig*.json', 'postcss.config.*', 'tailwind.config.*') }}
|
||||
restore-keys: |
|
||||
nextjs-${{ runner.os }}-node-${{ env.CI_NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-
|
||||
- run: npm run build
|
||||
env:
|
||||
OMNIROUTE_USE_TURBOPACK: "1"
|
||||
- name: Archive Next.js build for downstream jobs
|
||||
# Use tar so the archive preserves paths relative to CWD (.build/next/...).
|
||||
# upload-artifact path-stripping is ambiguous when exclude patterns are used;
|
||||
@@ -622,26 +548,12 @@ jobs:
|
||||
- name: Assert dist/server.js exists
|
||||
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
|
||||
- run: npm run check:pack-artifact
|
||||
# WS1.2 (#7065 class): pack the real tarball, install it into a clean prefix and
|
||||
# BOOT it to a healthy /api/monitoring/health — the gate that structure checks
|
||||
# cannot provide (3 releases shipped boot-crashing tarballs with green lists).
|
||||
- name: Boot-smoke the packed tarball
|
||||
run: npm run check:pack-boot
|
||||
|
||||
electron-package-smoke:
|
||||
name: Electron Package Smoke (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
name: Electron Package Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: build
|
||||
# WS1.5 (v3.8.49 plan): the Electron rebuild/spawn path previously executed for
|
||||
# the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned
|
||||
# without shell, CVE-2024-27980 behavior change) could only surface at release.
|
||||
# windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release
|
||||
# PR; ubuntu keeps the full pack + headless smoke.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
@@ -667,37 +579,18 @@ jobs:
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
- name: Pack Electron app
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: electron
|
||||
run: npm run pack
|
||||
# ADVISORY while the new Windows leg matures (repo convention, dast-smoke
|
||||
# precedent): its first-ever real run (2026-07-15, run 29457533565) died in
|
||||
# 0.7s with the error swallowed by pwsh — bash shell captures stderr and
|
||||
# continue-on-error keeps the heavy gate green while we harden it (#7336).
|
||||
- name: Prepare Electron standalone (Windows ABI rebuild + spawn path)
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: electron
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: npm run prepare:bundle 2>&1
|
||||
- name: Smoke packaged Electron app
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
||||
run: xvfb-run -a npm run electron:smoke:packaged
|
||||
|
||||
test-unit:
|
||||
name: Unit Tests (${{ matrix.shard }}/8)
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback 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' }}
|
||||
timeout-minutes: 25
|
||||
# needs: changes (not build) — this job never downloads the next-build artifact;
|
||||
# gating it on Build only serialized ~20min of wall-clock for nothing. Jobs that
|
||||
# DO consume the artifact (e2e, package-artifact, electron-package-smoke) keep
|
||||
# needs: build. The `if` mirrors Build's own skip condition so docs-only PRs
|
||||
# still skip the suite.
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -716,39 +609,13 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
# QW-d (plano mestre): fonte única — o MESMO npm script dos runs locais (adiciona o
|
||||
# setupPolyfill que o comando inline omitia; o glob canônico vive só no package.json).
|
||||
# D3 (plano mestre): a coverage é coletada NESTE mesmo run (c8/NODE_V8_COVERAGE propaga
|
||||
# aos filhos através do npm) — elimina a matrix Coverage Shard ×8, que re-executava a
|
||||
# suíte inteira só para medir o gate. Padrão usado pelo CI do próprio nodejs/node.
|
||||
- name: Unit tests (shard ${{ matrix.shard }}/8) with V8 coverage
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/8
|
||||
run: |
|
||||
rm -rf coverage-shard coverage-shard-report
|
||||
npx c8 \
|
||||
--temp-directory=coverage-shard \
|
||||
--reports-dir=coverage-shard-report \
|
||||
--reporter=json \
|
||||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
npm run test:unit:ci:shard
|
||||
- name: Upload raw shard coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-shard-${{ matrix.shard }}
|
||||
path: coverage-shard/*.json
|
||||
if-no-files-found: error
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
test-vitest:
|
||||
name: Vitest (MCP / autoCombo / UI components)
|
||||
# Same dynamic-runner rule as Build (own-origin only; fallback 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
needs: build
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
@@ -764,37 +631,145 @@ jobs:
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
|
||||
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
|
||||
# WS5.2/5.3 (v3.8.49 plan): JUnit output feeds Trunk Flaky Tests (advisory upload
|
||||
# below). node:test stays OUT of the first wave (fd1-sensitive reporter stream).
|
||||
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-mcp.xml
|
||||
# vitest:ui went back to 870/870 green in the v3.8.49 quality plan (WS6.1,
|
||||
# PR #7127 — 69 fails triaged: matchMedia polyfill, node:test→vitest migration,
|
||||
# CompareTab D22 cap). Promoted to BLOCKING per the plan's post-merge step.
|
||||
- run: npm run test:vitest:ui -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-ui.xml
|
||||
# Trunk Flaky Tests upload — advisory (never blocks), own-origin only (fork PRs
|
||||
# have no TRUNK_TOKEN). Pinned by SHA (tag v2.1.2).
|
||||
- name: Upload test results to Trunk (advisory)
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
- run: npm run test:vitest
|
||||
# vitest:ui is RED today (14 fails — UI component drift accumulated while the
|
||||
# suite never ran in CI). Informational until the Fase 6A triage (2026-06-16+)
|
||||
# fixes the components/tests; then drop continue-on-error to make it blocking.
|
||||
- run: npm run test:vitest:ui
|
||||
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 }}
|
||||
|
||||
# Node 24/26 compatibility matrices moved to .github/workflows/nightly-compat.yml
|
||||
# (plano mestre testes+CI, Eixo D2 — they cost ~28% of every heavy run to catch a
|
||||
# failure class that rarely originates in a PR; nightly catches it within 24h and
|
||||
# the release gate can still exercise them via workflow_dispatch when needed).
|
||||
node-24-compat:
|
||||
name: Node 24 Compatibility Tests (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
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"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_24_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
node-26-compat-build:
|
||||
name: Node 26 Compatibility Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: build
|
||||
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"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_26_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Next.js build cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae
|
||||
with:
|
||||
path: .build/next/cache
|
||||
key: nextjs-${{ runner.os }}-node-${{ env.CI_NODE_26_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*', 'open-sse/**/*', 'db/**/*', 'next.config.mjs', 'tsconfig*.json', 'postcss.config.*', 'tailwind.config.*') }}
|
||||
restore-keys: |
|
||||
nextjs-${{ runner.os }}-node-${{ env.CI_NODE_26_VERSION }}-${{ hashFiles('package-lock.json') }}-
|
||||
- run: npm run build
|
||||
|
||||
node-26-compat:
|
||||
name: Node 26 Compatibility Tests (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: node-26-compat-build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
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"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_26_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
test-coverage-shard:
|
||||
name: Coverage Shard (${{ matrix.shard }}/8)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
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"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Run c8 over shard ${{ matrix.shard }}/8
|
||||
run: |
|
||||
rm -rf coverage-shard coverage-shard-report
|
||||
# `--temp-directory` (writable via NODE_V8_COVERAGE) is what the merge
|
||||
# job reads with `c8 report --temp-directory ...`. Using `--output-dir`
|
||||
# only produces the final json *report* and leaves the raw v8 files in
|
||||
# `coverage/tmp`, so uploading `coverage-shard/` was empty. Pin the temp
|
||||
# dir so the raw coverage files live there and the artifact upload picks
|
||||
# them up regardless of `--test-force-exit` timing.
|
||||
npx c8 \
|
||||
--temp-directory=coverage-shard \
|
||||
--reports-dir=coverage-shard-report \
|
||||
--reporter=json \
|
||||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \
|
||||
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- name: Upload raw shard coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-shard-${{ matrix.shard }}
|
||||
path: coverage-shard/*.json
|
||||
if-no-files-found: error
|
||||
|
||||
test-coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
|
||||
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
|
||||
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
|
||||
timeout-minutes: 20
|
||||
needs: test-unit
|
||||
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
|
||||
timeout-minutes: 10
|
||||
needs: test-coverage-shard
|
||||
if: ${{ !cancelled() && needs.test-coverage-shard.result == 'success' }}
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
@@ -842,7 +817,6 @@ jobs:
|
||||
--merge-async \
|
||||
--reporter=text-summary \
|
||||
--reporter=json-summary \
|
||||
--reporter=lcov \
|
||||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
--check-coverage \
|
||||
@@ -864,18 +838,6 @@ jobs:
|
||||
> coverage/coverage-report.md
|
||||
fi
|
||||
cat coverage/coverage-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
# WS5.6 (D7, v3.8.49 plan): patch coverage on the PR diff via Codecov —
|
||||
# informational during calibration (codecov.yml sets informational: true);
|
||||
# promote to blocking only after ~2 weeks without false blocks. The lcov
|
||||
# reporter above also fixes coverage/lcov.info being silently absent
|
||||
# (if-no-files-found: warn) — Sonar consumes the same file.
|
||||
- name: Upload coverage to Codecov (informational)
|
||||
if: always()
|
||||
uses: codecov/codecov-action@04b047e8bb82a0c002c8312c1c880fbc6a999d45 # v5
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
- name: Upload coverage artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -884,7 +846,6 @@ jobs:
|
||||
path: |
|
||||
coverage/coverage-summary.json
|
||||
coverage/coverage-report.md
|
||||
coverage/lcov.info
|
||||
if-no-files-found: warn
|
||||
|
||||
sonarqube:
|
||||
@@ -900,14 +861,10 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# The upload strips the common `coverage/` prefix, so the artifact root holds
|
||||
# lcov.info directly — download into coverage/ so it lands at coverage/lcov.info,
|
||||
# where sonar.javascript.lcov.reportPaths expects it (path: . left the Sonar
|
||||
# new-code coverage at 0% every scan).
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
path: .
|
||||
- name: Explain SonarQube skip
|
||||
if: ${{ github.event_name != 'pull_request' || env.SONAR_TOKEN == '' || env.SONAR_HOST_URL == '' }}
|
||||
run: |
|
||||
@@ -916,33 +873,17 @@ jobs:
|
||||
else
|
||||
echo "SonarQube scan skipped because SONAR_TOKEN or SONAR_HOST_URL is not configured." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- name: Read project version
|
||||
if: ${{ github.event_name == 'pull_request' && env.SONAR_TOKEN != '' && env.SONAR_HOST_URL != '' }}
|
||||
id: pkg
|
||||
run: |
|
||||
VERSION="$(node -p "require('./package.json').version")"
|
||||
# Harden against output injection: only accept a plain semver-ish string.
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Invalid package.json version: $VERSION" >&2; exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: SonarQube Scan
|
||||
if: ${{ github.event_name == 'pull_request' && env.SONAR_TOKEN != '' && env.SONAR_HOST_URL != '' }}
|
||||
uses: SonarSource/sonarqube-scan-action@v8
|
||||
env:
|
||||
SONAR_TOKEN: ${{ env.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }}
|
||||
with:
|
||||
# sonar.projectVersion advances the "previous_version" new-code baseline at
|
||||
# each release; without it the baseline never moves (it stays pinned to the
|
||||
# analysis where the version last changed) and legacy issues pile up as
|
||||
# "new code" in the quality gate.
|
||||
args: -Dsonar.projectVersion=${{ steps.pkg.outputs.version }}
|
||||
|
||||
coverage-pr-comment:
|
||||
name: PR Coverage Comment
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.code == 'true' }}
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.code == 'true' }}
|
||||
needs:
|
||||
- changes
|
||||
- pr-test-policy
|
||||
@@ -1026,12 +967,7 @@ jobs:
|
||||
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
|
||||
# Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners.
|
||||
timeout-minutes: 45
|
||||
needs: [build, changes]
|
||||
# WS3.1 hotfix fast-lane: the 9-shard E2E matrix is the CI critical path (~25min).
|
||||
# It skips for (a) PRs labeled `hotfix` (entry policy in docs/ops/RELEASE_CHECKLIST.md:
|
||||
# production-broken only, full-suite evidence from the previous green run linked in the
|
||||
# PR) and (b) tests-only diffs outside tests/e2e/ (cannot change the served app).
|
||||
if: ${{ needs.changes.outputs.testsOnly != 'true' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1052,7 +988,7 @@ jobs:
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
@@ -1066,41 +1002,13 @@ jobs:
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
# WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json).
|
||||
# Measured skew of plain --shard was 14× (24m47s vs 1m47s) — E2E was the CI
|
||||
# critical path. The balancer self-verifies completeness and exits non-zero on
|
||||
# any inconsistency, falling back to plain --shard (never fewer specs).
|
||||
- name: Run E2E tests (duration-balanced shard)
|
||||
env:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
PLAYWRIGHT_JUNIT_OUTPUT_NAME: junit-e2e-results.xml
|
||||
run: |
|
||||
if FILES=$(node scripts/quality/balance-e2e-shards.mjs "$SHARD" 9); then
|
||||
if [ -z "$FILES" ]; then echo "[e2e-balance] shard $SHARD has no files"; exit 0; fi
|
||||
echo "[e2e-balance] shard $SHARD runs:"; echo "$FILES"
|
||||
# shellcheck disable=SC2086 — FILES is our own newline-separated path list
|
||||
npx playwright test $(echo "$FILES" | tr '\n' ' ') --reporter=line,junit
|
||||
else
|
||||
echo "[e2e-balance] balancer unavailable — plain --shard fallback"
|
||||
npx playwright test tests/e2e/*.spec.ts --shard="$SHARD"/9 --reporter=line,junit
|
||||
fi
|
||||
# WS5.2/5.3: Trunk Flaky Tests upload — advisory, own-origin only, SHA-pinned.
|
||||
- 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: junit-e2e-results.xml
|
||||
org-slug: omniroute
|
||||
token: ${{ secrets.TRUNK_TOKEN }}
|
||||
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
|
||||
|
||||
test-integration:
|
||||
name: Integration Tests (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1121,15 +1029,12 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
# (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up)
|
||||
- run: node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
- run: node --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
|
||||
test-security:
|
||||
name: Security Tests
|
||||
runs-on: ubuntu-latest
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
needs: build
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
@@ -1161,6 +1066,9 @@ jobs:
|
||||
- package-artifact
|
||||
- electron-package-smoke
|
||||
- test-unit
|
||||
- node-24-compat
|
||||
- node-26-compat-build
|
||||
- node-26-compat
|
||||
- test-coverage
|
||||
- sonarqube
|
||||
- coverage-pr-comment
|
||||
@@ -1210,12 +1118,15 @@ jobs:
|
||||
echo "| Build Matrix | $(status '${{ needs.build.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Package Artifact | $(status '${{ needs.package-artifact.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Electron Package Smoke | $(status '${{ needs.electron-package-smoke.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 26 Compatibility Build | $(status '${{ needs.node-26-compat-build.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## 🧪 Tests" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Suite | Status |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Unit | $(status '${{ needs.test-unit.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 24 Compatibility Tests | $(status '${{ needs.node-24-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 26 Compatibility Tests | $(status '${{ needs.node-26-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Coverage | $(status '${{ needs.test-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| PR Coverage Comment | $(status '${{ needs.coverage-pr-comment.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
7
.github/workflows/dast-smoke.yml
vendored
7
.github/workflows/dast-smoke.yml
vendored
@@ -10,10 +10,7 @@ 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
|
||||
@@ -45,7 +42,7 @@ jobs:
|
||||
- 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
|
||||
|
||||
23
.github/workflows/docker-publish.yml
vendored
23
.github/workflows/docker-publish.yml
vendored
@@ -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"
|
||||
@@ -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
|
||||
|
||||
9
.github/workflows/electron-release.yml
vendored
9
.github/workflows/electron-release.yml
vendored
@@ -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
|
||||
@@ -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:
|
||||
|
||||
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
|
||||
2
.github/workflows/nightly-mutation.yml
vendored
2
.github/workflows/nightly-mutation.yml
vendored
@@ -113,7 +113,7 @@ 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 }}
|
||||
|
||||
186
.github/workflows/nightly-release-green.yml
vendored
186
.github/workflows/nightly-release-green.yml
vendored
@@ -1,18 +1,11 @@
|
||||
name: Release-Green (continuous)
|
||||
name: Nightly Release-Green
|
||||
|
||||
# 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).
|
||||
# nightly reproduces the release-equivalent validation on the active release branch
|
||||
# HEAD and, when there are HARD failures, opens/updates a single tracking issue.
|
||||
#
|
||||
# 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
|
||||
@@ -21,23 +14,8 @@ name: Release-Green (continuous)
|
||||
# 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
|
||||
- cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
@@ -50,25 +28,13 @@ permissions:
|
||||
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 }}
|
||||
group: nightly-release-green
|
||||
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' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
@@ -83,15 +49,10 @@ jobs:
|
||||
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*' \
|
||||
@@ -125,26 +86,9 @@ jobs:
|
||||
|
||||
- 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 \
|
||||
node scripts/quality/validate-release-green.mjs --json --with-build \
|
||||
1> release-green.json 2> release-green.log
|
||||
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
||||
echo "------- report -------"
|
||||
@@ -156,28 +100,15 @@ jobs:
|
||||
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 "The nightly **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 "**Run:** ${RUN_URL}"
|
||||
echo ""
|
||||
echo '```'
|
||||
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
|
||||
@@ -197,107 +128,10 @@ jobs:
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
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
|
||||
|
||||
2
.github/workflows/nightly-resilience.yml
vendored
2
.github/workflows/nightly-resilience.yml
vendored
@@ -100,7 +100,7 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6.1.0
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
2
.github/workflows/nightly-schemathesis.yml
vendored
2
.github/workflows/nightly-schemathesis.yml
vendored
@@ -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 \
|
||||
|
||||
107
.github/workflows/npm-publish.yml
vendored
107
.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:
|
||||
@@ -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'
|
||||
@@ -261,8 +205,6 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
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
|
||||
|
||||
300
.github/workflows/quality.yml
vendored
300
.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:
|
||||
@@ -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/wiki-sync.yml
vendored
2
.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:
|
||||
|
||||
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''',
|
||||
]
|
||||
|
||||
@@ -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.)
|
||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -48,19 +48,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,26 +238,9 @@ 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"> {
|
||||
): Required<Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">> &
|
||||
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.
|
||||
@@ -275,7 +258,6 @@ export function resolveOmniRoutePluginOptions(
|
||||
: DEFAULT_MODEL_CACHE_TTL_MS;
|
||||
return {
|
||||
providerId,
|
||||
omnirouteProviderId,
|
||||
displayName,
|
||||
modelCacheTtl,
|
||||
baseURL: opts?.baseURL,
|
||||
@@ -283,18 +265,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.
|
||||
@@ -1255,7 +1225,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 +1233,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 +2046,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 +2125,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 +2174,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 };
|
||||
}
|
||||
@@ -2691,8 +2661,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,
|
||||
});
|
||||
@@ -2857,8 +2826,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
|
||||
);
|
||||
@@ -2877,8 +2845,7 @@ 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, resolved.providerId);
|
||||
|
||||
// Collision policy: combos win. Warn ONCE per (cacheKey, comboKey)
|
||||
// when overwriting a same-key raw model so the operator can spot
|
||||
@@ -2980,8 +2947,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 +3174,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
|
||||
@@ -4439,24 +4406,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
|
||||
|
||||
@@ -447,14 +447,14 @@ 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["opencode-omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-secondary"]);
|
||||
assert.ok(out["opencode-omniroute/gemini-3-flash"]);
|
||||
assert.ok(out["opencode-omniroute/claude-tier"]);
|
||||
|
||||
const combo = out["omniroute/claude-tier"];
|
||||
const combo = out["opencode-omniroute/claude-tier"];
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.providerID, "omniroute");
|
||||
assert.equal(combo.providerID, "opencode-omniroute");
|
||||
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
|
||||
assert.equal(combo.limit.context, 100_000);
|
||||
assert.equal(combo.capabilities.reasoning, false);
|
||||
@@ -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["opencode-omniroute/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["opencode-omniroute/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].limit.context, 0);
|
||||
});
|
||||
|
||||
test("models(): hidden combos are excluded from the map", async () => {
|
||||
@@ -505,8 +505,8 @@ 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["opencode-omniroute/visible"]);
|
||||
assert.ok(!out["opencode-omniroute/hidden"], "hidden combo must be omitted");
|
||||
});
|
||||
|
||||
test("models(): combo name exactly matches raw model id → raw deleted, raw deleted, no warn", async () => {
|
||||
@@ -530,8 +530,8 @@ test("models(): combo name exactly matches raw model id → raw deleted, raw del
|
||||
});
|
||||
|
||||
// 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");
|
||||
assert.ok(out["opencode-omniroute/claude-primary"], "combo surfaces under prefixed key");
|
||||
assert.equal(out["opencode-omniroute/claude-primary"].name, "claude-primary");
|
||||
|
||||
// No collision warning fires — dedup makes keys disjoint.
|
||||
const collisionWarns = warnings.filter((w) => {
|
||||
@@ -565,8 +565,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["opencode-omniroute/claude"], "first combo at prefixed slug");
|
||||
assert.ok(out["opencode-omniroute/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 +583,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["opencode-omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-secondary"]);
|
||||
|
||||
// Soft-fail warning surfaced.
|
||||
const softFail = warnings.find((w) => {
|
||||
@@ -609,7 +609,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["opencode-omniroute/claude-tier"]);
|
||||
});
|
||||
|
||||
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
|
||||
@@ -701,7 +701,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["opencode-omniroute/master-light"];
|
||||
assert.ok(masterLight, "MASTER-LIGHT entry must exist");
|
||||
assert.equal(
|
||||
masterLight.limit.context,
|
||||
|
||||
@@ -256,65 +256,6 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
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
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1311,9 +1252,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",
|
||||
},
|
||||
],
|
||||
])
|
||||
@@ -1335,7 +1276,10 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
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["opencode-omniroute/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");
|
||||
});
|
||||
|
||||
@@ -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["opencode-omniroute/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);
|
||||
@@ -403,7 +402,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,
|
||||
out["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id preserved"
|
||||
);
|
||||
@@ -464,7 +463,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["opencode-omniroute/claude-primary"];
|
||||
assert.ok(combo, "combo entry present");
|
||||
assert.match(
|
||||
combo.name,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -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["opencode-omniroute/claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -155,17 +152,13 @@ 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["opencode-omniroute/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, "opencode-omniroute/claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.providerID, "opencode-omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
assert.equal(claude.api.url, "https://or.example.com/v1");
|
||||
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");
|
||||
|
||||
86
AGENTS.md
86
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 **231 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.31)**: providers 231 · 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,33 @@ 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) |
|
||||
| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
2140
CHANGELOG.md
2140
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
149
CLAUDE.md
149
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, 231 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)
|
||||
@@ -541,11 +505,6 @@ the stale-enforcement added in Fase 6A.3.
|
||||
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)
|
||||
|
||||
254
DESING.md
Normal file
254
DESING.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# OmniRoute — Design System & Visual Identity
|
||||
|
||||
> **Status:** analysis + standardization plan (no code applied yet — this doc is the spec to approve before implementation).
|
||||
> **Date:** 2026-06-16 · **Scope:** unify the OmniRoute dashboard (`src/`) with the marketing site (`_mono_repo/omnirouteSite/`) into **one visual identity** — same graph-paper grid background, same color tokens, standardized components.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The marketing site (`viral.omniroute.online`, `why.omniroute.online`, `omniroute.online`) and the product dashboard should look like **one product**. The site already borrowed its palette from the dashboard — its `css/tokens.css` even says _"Palette mirrors the OmniRoute dashboard (src/app/globals.css)"_. So the two are already ~80% aligned at the color level. What's missing on the dashboard:
|
||||
|
||||
1. The **graph-paper grid wallpaper** the site uses on every page.
|
||||
2. A handful of **shared design tokens** the site has but the dashboard lacks (radius scale, brand gradient, `surface-2`, mono font).
|
||||
3. **Component-level consistency** — a number of dashboard components bypass the theme tokens with hardcoded hex/rgba.
|
||||
|
||||
This document is the analysis and the plan. **Nothing is changed until approved.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principles
|
||||
|
||||
- **Single source of truth = `src/app/globals.css`.** The site mirrors the dashboard, never the other way around. New tokens land in `globals.css` first.
|
||||
- **Tokens, never literals.** Components consume semantic tokens (`bg-surface`, `text-primary`, `border-border`), never raw `#hex`.
|
||||
- **Subtle, not loud.** The grid is a faint wallpaper that sits behind content — it must never reduce text contrast or fight the UI.
|
||||
- **Theme-aware.** Everything works in both `.dark` (default-ish, the product's signature look) and light.
|
||||
- **Surgical rollout.** Ship the grid + tokens first (low risk, high visibility), then component cleanups in waves.
|
||||
|
||||
---
|
||||
|
||||
## 3. Current state — what's already aligned vs. what's not
|
||||
|
||||
### 3.1 Colors — already unified ✅
|
||||
|
||||
Every brand color and surface already matches the site **by value** (only the names differ — dashboard prefixes with `--color-`). Verified in `src/app/globals.css:30-128`:
|
||||
|
||||
| Concept | Site token (`tokens.css`) | Dashboard token (`globals.css`) | Match |
|
||||
| -------------------------- | ------------------------------------------- | ------------------------------- | ------------ |
|
||||
| primary | `--primary #e54d5e` | `--color-primary #e54d5e` | ✅ |
|
||||
| primary-hover | `--primary-hover #c93d4e` | `--color-primary-hover #c93d4e` | ✅ |
|
||||
| accent | `--accent #6366f1` | `--color-accent #6366f1` | ✅ |
|
||||
| accent-2 | `--accent-2 #8b5cf6` | `--color-accent-hover #8b5cf6` | ✅ (renamed) |
|
||||
| accent-3 | `--accent-3 #a855f7` | `--color-accent-light #a855f7` | ✅ (renamed) |
|
||||
| success / warning / error | `#22c55e / #f59e0b / #ef4444` | identical | ✅ |
|
||||
| traffic lights | `#ff5f56 / #ffbd2e / #27c93f` | identical | ✅ |
|
||||
| dark bg / surface / border | `#0b0e14 / #161b22 / rgba(255,255,255,.08)` | identical | ✅ |
|
||||
| light bg / surface / text | `#f9f9fb / #fff / #1a1a2e` | identical | ✅ |
|
||||
|
||||
**Conclusion:** there is no color migration to do. The identity is already shared; we are _finishing_ it, not rebuilding it.
|
||||
|
||||
### 3.2 Gaps — what the dashboard is missing
|
||||
|
||||
| Gap | Site has | Dashboard | Action |
|
||||
| ----------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------- | ---------------------- |
|
||||
| **Grid wallpaper** | `body::before` graph-paper, `--grid-line`, `--grid-size 46px`, `--section-alt` | none (flat `--color-bg`) | **Part A** |
|
||||
| **Radius scale** | `--radius 14px`, `--radius-sm 9px` | none — primitives use ad-hoc `rounded-md/lg/xl` (6/8/12px) | **Part B** |
|
||||
| **Brand gradient** | `--grad-brand 135deg primary→accent-3` | none — only a one-off `.bg-hero-gradient` | **Part B** |
|
||||
| **Nested surface** | `--surface-2 #1c2230` | none | **Part B** |
|
||||
| **Mono font** | `--font-mono` (ui-monospace stack) | none (code/terminal areas have no token) | **Part B** |
|
||||
| **`text-muted` (dark)** | `#8b8b9e` | `#a1a1aa` (zinc-400) | reconcile — **Part B** |
|
||||
|
||||
### 3.3 Theming mechanics (so we don't break anything)
|
||||
|
||||
- **Tailwind v4, CSS-first** (no `tailwind.config.*`). Tokens are defined in `:root`/`.dark` and exposed to utilities via `@theme inline` (`globals.css:130-179`).
|
||||
- **Dark via `.dark` class** on `<html>` (`@custom-variant dark` at `globals.css:22`), toggled by a custom Zustand store (`src/store/themeStore.ts`), default theme = `system` (`src/shared/constants/appConfig.ts:11`). The site uses `html[data-theme="light"]` instead — **the mechanisms differ but never meet** (separate origins), so no conflict. We keep the dashboard's `.dark` mechanism.
|
||||
- **Runtime primary override** exists (`themeStore.ts:85-97`, presets in `COLOR_THEMES`) — users can swap `--color-primary`. Any new token (gradient, etc.) that references `--color-primary` will inherit those overrides for free. ✅
|
||||
|
||||
---
|
||||
|
||||
## 4. Part A — The graph-paper grid background (headline ask)
|
||||
|
||||
### 4.1 What it is
|
||||
|
||||
The exact recipe from the site (`_mono_repo/omnirouteSite/css/base.css`): a **fixed, full-viewport pseudo-element** painting two 1px line gradients, sitting at `z-index:-1` behind all content.
|
||||
|
||||
```css
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: var(--grid-size) var(--grid-size);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works even though `body` has an opaque `background-color`:** a `::before` with `z-index:-1` paints _above_ the element's own background but _below_ its in-flow content. So `--color-bg` is the base fill, the grid is layered on top of it, and the app renders above the grid.
|
||||
|
||||
### 4.2 Precedent already in the codebase
|
||||
|
||||
`src/app/landing/page.tsx:16-26` **already implements this same grid per-page** — but with **red** lines (`#E54D5E`, opacity `0.06`) at **50px**, plus animated orbs. So the pattern is proven in the product; we are promoting it to a **global, theme-aware** wallpaper and (optionally) retiring the duplicate.
|
||||
|
||||
### 4.3 Tokens to add (in `globals.css`)
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* light */
|
||||
--grid-line: rgba(0, 0, 0, 0.045);
|
||||
--grid-size: 46px;
|
||||
--section-alt: rgba(0, 0, 0, 0.022);
|
||||
}
|
||||
.dark {
|
||||
/* dark */
|
||||
--grid-line: rgba(255, 255, 255, 0.035);
|
||||
--section-alt: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 The single blocker
|
||||
|
||||
The grid is global by construction (it covers the panel, `auth`/`login`, error pages — every route — at once). Exactly **one** element hides it inside the panel:
|
||||
|
||||
- `src/shared/components/layouts/DashboardLayout.tsx:62` — the outer wrapper paints an opaque `bg-bg`:
|
||||
|
||||
```jsx
|
||||
<div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
```
|
||||
|
||||
Everything below it is already transparent — `<main>` (`:93`), the scroll container (`:102`), the `max-w-7xl` inner (`:103`). So **removing `bg-bg` from this one line** lets the body grid show through the entire content area (the body's `--color-bg` remains the base fill underneath the grid).
|
||||
|
||||
```diff
|
||||
- <div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
+ <div className="flex h-dvh min-h-0 w-full overflow-hidden">
|
||||
```
|
||||
|
||||
### 4.5 Chrome interaction (sidebar / header)
|
||||
|
||||
- `Header` (`src/shared/components/Header.tsx:207`, `bg-bg`) and `Sidebar` (`src/shared/components/Sidebar.tsx:430`, `bg-sidebar`) stay **opaque** → the grid shows in the **content area only**, with solid chrome framing it. This is the recommended, calm default and matches how the site separates chrome from canvas.
|
||||
- _Optional vibrancy variant:_ make the header translucent (`bg-bg/80 backdrop-blur`) so the grid runs behind it. A `.bg-vibrancy` helper already exists (`globals.css:370`). **Decision D3 below.**
|
||||
|
||||
### 4.6 Login / auth / error pages
|
||||
|
||||
These render directly under `<body>` (no panel chrome) and their page wrappers are mostly transparent — the global grid appears behind them automatically. One exception: `src/app/login/page.tsx:124,139` uses opaque `bg-bg` wrappers; soften the same way if we want the grid there too (minor, **D4**).
|
||||
|
||||
### 4.7 Landing page
|
||||
|
||||
`landing/page.tsx` keeps its richer animated background (orbs + vignette). Options: (a) leave it as-is (its own splash identity), or (b) align its grid to the global tokens (46px, neutral lines) for consistency. **Recommend (a)** — it's a marketing splash, not a panel screen. **Decision D5.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Part B — Token unification
|
||||
|
||||
Add to `globals.css` (`:root` + `@theme inline`) so the dashboard gains the site's missing tokens. None of these change existing colors; they add the _missing_ primitives.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--surface-2: #f5f5fa; /* light: nested panels */
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--grad-brand: linear-gradient(135deg, var(--color-primary), var(--color-accent-light));
|
||||
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
}
|
||||
.dark {
|
||||
--surface-2: #1c2230;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-surface-2: var(--surface-2); /* enables bg-surface-2 */
|
||||
--radius-lg: var(--radius); /* enables rounded-lg = 14px */
|
||||
--radius-md: var(--radius-sm); /* enables rounded-md = 9px */
|
||||
--font-mono: var(--font-mono); /* enables font-mono */
|
||||
}
|
||||
```
|
||||
|
||||
| Token | Why | Consumers |
|
||||
| -------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `--radius` / `--radius-sm` | One radius scale (14/9) instead of 6/8/12 ad-hoc | Button, Card, Modal, Input, Select |
|
||||
| `--grad-brand` | Brand gradient for primary CTAs (red→violet), matching the site | Button `primary`, hero/CTA surfaces |
|
||||
| `--surface-2` | Nested panels / table headers / inset rows | Card.Section, DataTable header, inputs |
|
||||
| `--font-mono` | Code blocks, terminal, IDs, endpoints | ConsoleLogViewer, code snippets, `localhost:20128/v1` chips |
|
||||
| `--text-muted` reconcile | Pick one value site↔panel | global |
|
||||
|
||||
**Decision D2 (text-muted):** site `#8b8b9e` vs dashboard `#a1a1aa`. Recommend keeping the **dashboard's `#a1a1aa`** (it's the live product, slightly higher contrast) and updating the _site_ to match. Low priority, cosmetic.
|
||||
|
||||
---
|
||||
|
||||
## 6. Part C — Component standardization
|
||||
|
||||
The component layer is **custom** (no shadcn/Radix), Tailwind v4, semantic tokens **mostly** adopted (`bg-surface`, `border-white/10`, `ring-primary`) — good adoption (195 files import the shared barrel). The work is removing the **bypasses**. Home: `src/shared/components/`.
|
||||
|
||||
Ranked by impact × reach:
|
||||
|
||||
| # | Item | File(s) | Problem → Target |
|
||||
| --- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| C1 | **Radius alignment** | `Button.tsx:14-18`, `Card.tsx:39`, `Modal.tsx`, `Input.tsx`, `Select.tsx` | mixed 6/8/12px → repoint to `--radius`/`--radius-sm` (14/9) |
|
||||
| C2 | **Button gradient + `accent` variant** | `Button.tsx:5-12` | primary is flat red→red (`from-primary to-primary-hover`); align to `--grad-brand` (red→violet) and add the missing `accent` variant (indigo `#6366f1` is unused by buttons) — **highest visibility, ~195 importers**. **Decision D1.** |
|
||||
| C3 | **Tables** | `DataTable.tsx:122-176`, `logTableStyles.ts`, `globals.css:405-414` (Ant remnants) | `DataTable` is 100% inline hardcoded rgba + references non-existent vars (`--text-secondary`, `--bg-table-header`); migrate to tokens, retire the 2 divergent table styles. Tables are everywhere (providers/connections/logs) — worst offender. |
|
||||
| C4 | **Centralize status colors** | `flow/edgeStyles.ts:7-12`, `TokenHealthBadge.tsx:14-19`, `DegradationBadge.tsx`, `ProviderCascadeNode.tsx`, `Badge.tsx`, +5 `statusColor` helpers | 6+ copies of the same `#22c55e/#f59e0b/#ef4444` hex; create one `statusColors` module driven off `--color-success/warning/error`. Critical for circuit-breaker / cooldown / lockout badges to read consistently. |
|
||||
| C5 | **Card border** | `Card.tsx:39` | uses `border-white/5`; brand border is `/8` → align |
|
||||
| C6 | **Focus ring reconcile** | `globals.css:183` vs component `ring-primary/30` | global `:focus-visible` is indigo (`--color-accent`), components are red (`ring-primary`) — pick one (recommend **accent/indigo** globally, it reads as the "interactive" color) |
|
||||
| C7 | **Add `Checkbox` + `Textarea` primitives** | currently raw `<input>`/`<textarea>` with inline `accentColor:#6366f1` (e.g. `ColumnToggle.tsx:91`) | create token-driven primitives |
|
||||
| C8 | **Hardcoded-hex sweep** | `ConsoleLogViewer.tsx:240` (`#161b22`/`#30363d`), `ComboLiveStudio.tsx:306` (`#6366f120`), Modal traffic dots `Modal.tsx:149-159`, ~14 chart/component files with literal `#6366f1`/`#a855f7` | replace literals with `bg-surface`/`border-border`/`text-accent` etc. |
|
||||
| C9 | **`cn()` → clsx + tailwind-merge** | `src/shared/utils/cn.ts` | current `cn` just joins; conflicting classes stack (a `className="rounded-2xl"` override won't replace a primitive's `rounded-lg`). Needed for C1 overrides to behave. |
|
||||
|
||||
**Already on-brand (token-driven, only need radius):** `Badge`, `Toggle`, `SegmentedControl`, `Input`, `Select`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout plan (phased, each phase shippable + testable)
|
||||
|
||||
- **Phase 1 — Grid + tokens (low risk, high visibility).**
|
||||
1. Add grid + identity tokens to `globals.css` (Part A §4.3, Part B §5).
|
||||
2. Add `body::before` grid.
|
||||
3. Remove `bg-bg` from `DashboardLayout.tsx:62`.
|
||||
4. Verify across themes + key screens (dashboard, providers, logs, login, an error page). Confirm contrast unchanged.
|
||||
→ _Delivers the headline ask. Reversible in one commit._
|
||||
|
||||
- **Phase 2 — Primitives radius + Button (C1, C2, C5, C9).** The visible "feel" pass. `cn()` upgrade first so overrides behave.
|
||||
|
||||
- **Phase 3 — Tables + status colors (C3, C4).** The largest consistency win; touch the data-heavy screens.
|
||||
|
||||
- **Phase 4 — Cleanup (C6, C7, C8).** Focus ring, new primitives, hardcoded-hex sweep.
|
||||
|
||||
Each phase: `npm run lint` + `npm run typecheck:core` + a visual pass. Per repo rule, production-code changes ship with tests where applicable (token/CSS changes are visual — validated by screenshots; component API changes get unit coverage).
|
||||
|
||||
---
|
||||
|
||||
## 8. Open decisions (need your call before/while implementing)
|
||||
|
||||
- **D1 — Button primary look.** Keep the current **red→red** gradient, or switch the product's primary buttons to the **red→violet `--grad-brand`** (matches the site CTAs)? _(Affects every primary button.)_ Recommend: **red→violet**, with `--grad-brand`.
|
||||
- **D2 — Grid line color.** **Neutral** lines (site style: faint white/black, `rgba(255,255,255,0.035)`) — calm, content-first — **or** the landing's **brand-red** lines? Recommend: **neutral** (matches the site's interior pages; red is louder and can tint readability). Size **46px** (site) to retire the landing's 50px drift.
|
||||
- **D3 — Chrome vibrancy.** Sidebar/header stay **solid** (grid in content area only), or go **translucent** so the grid runs behind them? Recommend: **solid** (calmer; less risk).
|
||||
- **D4 — Auth/login grid.** Soften `login/page.tsx` wrappers so the grid shows there too? Recommend: **yes** (cheap, more cohesive).
|
||||
- **D5 — Landing page.** Leave its animated splash bg as-is, or align it to the global grid? Recommend: **leave as-is**.
|
||||
- **D6 — Radius value.** Adopt **14/9** everywhere (bigger, softer, site-matching) — confirm you want this product-wide shift. Recommend: **yes**, it's the single biggest "one identity" signal.
|
||||
- **D7 — Scope of first PR.** Ship **Phase 1 only** first (grid + tokens), then iterate? Recommend: **yes** — validate the wallpaper live before the component waves.
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of scope / risks
|
||||
|
||||
- **No palette change** — colors already match; we only add missing tokens. Zero risk of recoloring the product.
|
||||
- **No theme-engine change** — keep `.dark` + Zustand store; don't migrate to `next-themes` or to the site's `data-theme`.
|
||||
- **Radius shift is broad** (D6) — it touches every card/button/input; that's the point, but it's the one change worth eyeballing on busy screens (tables, modals) before merge.
|
||||
- **Tables (C3)** carry the most hardcoded styling and the highest regression surface — isolate in its own PR with before/after screenshots.
|
||||
- **Worktree isolation (repo hard-rule #19):** implementation runs in a dedicated worktree on a branch cut from the confirmed base (likely `release/v3.8.28`), never on the shared checkout. This doc is the only artifact written to the working tree so far.
|
||||
|
||||
---
|
||||
|
||||
## 10. Reference index
|
||||
|
||||
| Area | Path |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dashboard tokens | `src/app/globals.css:30-179` (`:root`, `.dark`, `@theme inline`), `body` `:206` |
|
||||
| Theme store | `src/store/themeStore.ts`, `src/shared/components/ThemeProvider.tsx`, `src/shared/constants/appConfig.ts:9-11` |
|
||||
| Panel shell (grid blocker) | `src/shared/components/layouts/DashboardLayout.tsx:62` |
|
||||
| Chrome | `src/shared/components/Header.tsx:207`, `src/shared/components/Sidebar.tsx:430` |
|
||||
| Grid precedent | `src/app/landing/page.tsx:16-26` |
|
||||
| Primitives | `src/shared/components/{Button,Card,Input,Select,Badge,Modal,Toggle,SegmentedControl,Loading,Tooltip,DataTable}.tsx`, barrel `index.tsx` |
|
||||
| Status-color sources | `flow/edgeStyles.ts`, `TokenHealthBadge.tsx`, `DegradationBadge.tsx`, `logTableStyles.ts` |
|
||||
| `cn` util | `src/shared/utils/cn.ts` |
|
||||
| Site reference | `_mono_repo/omnirouteSite/css/tokens.css`, `css/base.css` (grid `body::before`) |
|
||||
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
|
||||
|
||||
607
README.md
607
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 **231 providers** — **50+ free** — through one endpoint.
|
||||
|
||||
**Plug Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini. Auto-fallback.**
|
||||
<br/>
|
||||
@@ -19,23 +19,11 @@
|
||||
|
||||
<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)
|
||||
[](#-231-ai-providers--50-free)
|
||||
[](#-231-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**](#-231-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>
|
||||
@@ -149,18 +137,18 @@
|
||||
|
||||
</div>
|
||||
|
||||
> One endpoint. **250 providers.** Never stop building — and let OmniRoute pick the cheapest one that works.
|
||||
> One endpoint. **231 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 231 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 | **231** | 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>
|
||||
|
||||
@@ -337,23 +287,18 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
</div>
|
||||
|
||||
> Recent highlights from **v3.8.20 → v3.8.47**. Full history in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
> Recent highlights from **v3.8.20 → v3.8.33**. 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)
|
||||
- **🤖 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, Gemini CLI); `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`). → [Remote Mode](docs/guides/REMOTE-MODE.md)
|
||||
- **🧭 Smarter auto-routing** — OpenRouter-style `auto/<category>:<tier>` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), live Arena-ELO + models.dev model intelligence, per-step account allowlists, provider-wildcard combo steps, nested combo-ref execution, sticky weighted selection, and `web_search`-aware routing to a configured model. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
- **🗜️ Pluggable compression** — an async compression pipeline with Compression Studios, a stable LLMLingua-2 ONNX engine, RTK, delegated Anthropic Context Editing, and a unified settings panel with named profiles + an active-profile selector as the single source for per-engine on/off + level. → [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)
|
||||
- **🧠 Memory you control** — opt-in int8 vector quantization (Qdrant + sqlite-vec), 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)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), a refreshed 231-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech / transcription / music / video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **⚡ Local performance & infra** — a one-click local Redis launcher (`omniroute redis up`, plus a dashboard Redis panel) and an optional Bifrost Go sidecar that offloads the hottest relay path (`BIFROST_BASE_URL`, with automatic fallback to the TypeScript path on timeout). → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -368,6 +313,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 +333,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 +341,28 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 🌐 250 AI Providers — 90+ Free
|
||||
# 🌐 231 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: **231 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 +379,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 +418,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`)
|
||||
|
||||
@@ -544,7 +458,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 +477,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 +488,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 +527,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 +538,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 +561,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 +613,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 +651,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 +705,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 +730,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 +763,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 +787,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 231 providers.
|
||||
|
||||
📖 [User Guide](docs/guides/USER_GUIDE.md) · [API Reference](docs/reference/API_REFERENCE.md) · [Environment Config](docs/reference/ENVIRONMENT.md)
|
||||
|
||||
@@ -977,7 +859,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 +877,66 @@ 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 |
|
||||
| [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 |
|
||||
|
||||
### 🔧 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 +950,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 +974,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 +1001,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 +1030,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/>
|
||||
|
||||
@@ -1185,64 +1070,62 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router]
|
||||
|
||||
| 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. |
|
||||
| **[9router](https://github.com/decolua/9router)** · decolua | 17.9k | 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 | 37.8k | The Go implementation that inspired this JavaScript / TypeScript port. |
|
||||
| **[LiteLLM](https://github.com/BerriAI/litellm)** · BerriAI | 50.8k | The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing. |
|
||||
|
||||
### 🗜️ Context & token compression — engines
|
||||
|
||||
| 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). |
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ---------------------------------------------------------------------------- | ----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[Caveman](https://github.com/JuliusBrussee/caveman)** · JuliusBrussee | 74.5k | 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 | 63.6k | 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/chopratejas/headroom)** · chopratejas | 33.6k | Reversible context-compression (SmartCrusher) — inspired our `headroom` engine and the `ccr` retrieve-marker pattern. |
|
||||
| **[LLMLingua](https://github.com/microsoft/LLMLingua)** · Microsoft | 6.3k | 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 | 27 | 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 | 15 | PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar. |
|
||||
|
||||
### 🧩 Compact formats, token research & code-aware tooling
|
||||
|
||||
| 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. |
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ---------------------------------------------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **[TOON](https://github.com/toon-format/toon)** · toon-format | 24.6k | 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 | 11 | Schema-aware "JSON for LLMs" notation — co-inspired our lossless homogeneous-array compaction with `[N rows]` markers. |
|
||||
| **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** · ooples | 409 | Brotli/SQLite cache + per-session context-delta — inspired our `session-dedup` engine. |
|
||||
| **[token-savior](https://github.com/Mibayy/token-savior)** · Mibayy | 993 | 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 | 103 | 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.4k | "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 | 1 | A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design. |
|
||||
| **[mcp-compressor](https://github.com/atlassian-labs/mcp-compressor)** · Atlassian Labs | 80 | MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction. |
|
||||
| **[RepoMapper](https://github.com/pdavis68/RepoMapper)** · pdavis68 | 182 | 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. |
|
||||
|
||||
### 🧠 Memory & RAG
|
||||
|
||||
| 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. |
|
||||
| **[Mem0](https://github.com/mem0ai/mem0)** · mem0ai | 58.9k | 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.4k | 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. |
|
||||
|
||||
### 🛰️ Traffic inspection, MITM & transparent proxy
|
||||
|
||||
| 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. |
|
||||
| **[llm-interceptor](https://github.com/chouzz/llm-interceptor)** · chouzz | 46 | 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.1k | Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, `/proc` process attribution and TPROXY capture. |
|
||||
|
||||
### 📚 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. |
|
||||
| **[models.dev](https://github.com/anomalyco/models.dev)** · SST / OpenCode | 5.1k | 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.1k | The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio. |
|
||||
| **[LangGraph](https://github.com/langchain-ai/langgraph)** · LangChain | 35.1k | LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view. |
|
||||
| **[Langfuse](https://github.com/langfuse/langfuse)** · Langfuse | 29.3k | 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. |
|
||||
| **[lobe-icons](https://github.com/lobehub/lobe-icons)** · LobeHub | 2.1k | AI/LLM brand logos that render the provider icons across our dashboard. |
|
||||
|
||||
### 🛡️ Security
|
||||
|
||||
@@ -1250,12 +1133,6 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router]
|
||||
| ------------------------------------------------------------------------------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **[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. |
|
||||
|
||||
## ❤️ Support
|
||||
|
||||
OmniRoute is free and open source, built and maintained in the open. If it saves you time or money, consider supporting development:
|
||||
@@ -1274,7 +1151,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";
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,16 +19,6 @@ const STRIPPED_CODEX_ENV_KEYS = [
|
||||
/** 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;
|
||||
@@ -136,10 +126,10 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
|
||||
if (!(await healthCheck(baseUrl))) {
|
||||
console.error(
|
||||
(
|
||||
t("launch.notRunning") ||
|
||||
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
|
||||
).replace("{port}", baseUrl)
|
||||
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
|
||||
"{port}",
|
||||
baseUrl
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -152,12 +142,7 @@ export async function runLaunchCodexCommand(opts = {}, 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,
|
||||
});
|
||||
const child = spawn("codex", extraArgs, { env, stdio: "inherit" });
|
||||
child.on("error", (err) => {
|
||||
if (err?.code === "ENOENT") {
|
||||
console.error(
|
||||
@@ -180,16 +165,10 @@ export function registerLaunchCodex(program) {
|
||||
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("--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)"
|
||||
)
|
||||
.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")
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/**
|
||||
* `omniroute login antigravity` — local OAuth helper for remote installs.
|
||||
*
|
||||
* Why this exists: Google's `firstparty/nativeapp` consent for the embedded
|
||||
* Antigravity desktop client only releases the authorization code when the
|
||||
* loopback redirect (127.0.0.1:<port>) is REACHABLE. On a remote VPS install the
|
||||
* loopback is unreachable, so the consent hangs forever and never emits a code —
|
||||
* the dashboard's "paste the callback URL" fallback has nothing to paste. (The
|
||||
* same flow works locally and over an SSH tunnel, where the loopback IS reachable.)
|
||||
*
|
||||
* This command runs the OAuth on the user's OWN machine — where 127.0.0.1 works —
|
||||
* captures the code on a local loopback server, exchanges it for tokens, and
|
||||
* prints a single-line credential blob. The user pastes that blob into the remote
|
||||
* dashboard (Antigravity → "Paste credentials"), which decodes it, finalizes the
|
||||
* onboarding server-side, and persists the connection.
|
||||
*
|
||||
* It talks ONLY to Google (no OmniRoute server needed locally), so it works even
|
||||
* if the remote VPS is firewalled from the user's machine.
|
||||
*/
|
||||
|
||||
const PROVIDER = "antigravity";
|
||||
|
||||
/** Open the system browser; no-op if the optional `open` dependency is missing. */
|
||||
async function defaultOpenBrowser(url) {
|
||||
try {
|
||||
const { default: open } = await import("open");
|
||||
await open(url);
|
||||
} catch {
|
||||
// `open` not available — the caller already printed the URL to paste manually.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a loopback HTTP server bound to 127.0.0.1 (NOT 0.0.0.0 — we never want to
|
||||
* expose the callback to the LAN). Resolves to { port, waitForCallback, close }.
|
||||
*/
|
||||
function defaultStartServer(preferredPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolveCallback;
|
||||
const callbackPromise = new Promise((r) => {
|
||||
resolveCallback = r;
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
const params = Object.fromEntries(url.searchParams.entries());
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(
|
||||
"<!doctype html><meta charset=utf-8><title>OmniRoute</title>" +
|
||||
"<body style=\"font-family:system-ui;padding:2rem\">" +
|
||||
"<h2>✅ Authorization received</h2>" +
|
||||
"<p>Return to your terminal — you can close this tab.</p></body>"
|
||||
);
|
||||
resolveCallback(params);
|
||||
});
|
||||
|
||||
server.on("error", reject);
|
||||
server.listen(preferredPort || 0, "127.0.0.1", () => {
|
||||
const { port } = server.address();
|
||||
resolve({
|
||||
port,
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Lazy-load the antigravity provider + blob codec (TS source via tsx). */
|
||||
async function loadDeps() {
|
||||
const { antigravity } = await import("../../../src/lib/oauth/providers/antigravity.ts");
|
||||
const { encodeCredentialBlob } = await import("../../../src/lib/oauth/credentialBlob.ts");
|
||||
return { antigravity, encodeCredentialBlob };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Google authorization request for a given loopback port. Uses a plain
|
||||
* authorization_code grant (NO PKCE code_challenge) — matching the working flow:
|
||||
* a code_challenge here would force the exchange to require a code_verifier.
|
||||
*/
|
||||
export async function buildAntigravityAuthRequest(port, makeState = randomUUID) {
|
||||
const { antigravity } = await loadDeps();
|
||||
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
||||
const state = makeState();
|
||||
const authUrl = antigravity.buildAuthUrl(antigravity.config, redirectUri, state);
|
||||
return { redirectUri, state, authUrl };
|
||||
}
|
||||
|
||||
/** Exchange the captured code for raw Google tokens (no code_verifier — no PKCE). */
|
||||
export async function exchangeAntigravityCode(code, redirectUri) {
|
||||
const { antigravity } = await loadDeps();
|
||||
return antigravity.exchangeToken(antigravity.config, code, redirectUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrate the local login. Dependencies are injectable for testing; the real
|
||||
* path uses a 127.0.0.1 loopback server, the system browser, and a live token
|
||||
* exchange against Google. Returns the credential blob string.
|
||||
*/
|
||||
export async function runAntigravityLogin(opts = {}, deps = {}) {
|
||||
const startServer = deps.startServer ?? defaultStartServer;
|
||||
const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
|
||||
const exchange = deps.exchange ?? exchangeAntigravityCode;
|
||||
const makeState = deps.makeState ?? randomUUID;
|
||||
const print = deps.print ?? ((s) => process.stdout.write(s));
|
||||
const log = deps.log ?? ((s) => process.stderr.write(s));
|
||||
const { encodeCredentialBlob } = await loadDeps();
|
||||
|
||||
const server = await startServer(opts.port);
|
||||
const { redirectUri, state, authUrl } = await buildAntigravityAuthRequest(server.port, makeState);
|
||||
|
||||
log(`\nOpen this URL to authorize Antigravity (it will open automatically):\n\n ${authUrl}\n\n`);
|
||||
if (opts.browser !== false) await openBrowser(authUrl);
|
||||
log("Waiting for Google to redirect back to the local loopback...\n");
|
||||
|
||||
const timeoutMs = opts.timeout ?? 300000;
|
||||
let timer;
|
||||
let params;
|
||||
try {
|
||||
params = await Promise.race([
|
||||
server.waitForCallback(),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Timed out waiting for the OAuth callback")),
|
||||
timeoutMs
|
||||
);
|
||||
// Don't keep the event loop alive solely for this timer.
|
||||
if (typeof timer.unref === "function") timer.unref();
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
await server.close();
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
throw new Error(`Authorization failed: ${params.error_description || params.error}`);
|
||||
}
|
||||
if (params.state !== state) {
|
||||
throw new Error("State mismatch — aborting (possible CSRF). Please retry the login.");
|
||||
}
|
||||
if (!params.code) {
|
||||
throw new Error("No authorization code returned by Google.");
|
||||
}
|
||||
|
||||
const tokens = await exchange(params.code, redirectUri);
|
||||
const blob = encodeCredentialBlob({ provider: PROVIDER, tokens });
|
||||
|
||||
print(
|
||||
"\n" +
|
||||
"Antigravity authorized. Copy the line below and paste it into your remote\n" +
|
||||
"OmniRoute dashboard: Providers → Antigravity → Connect → \"Paste credentials\".\n" +
|
||||
"(This contains a refresh token — treat it like a password.)\n\n" +
|
||||
blob +
|
||||
"\n\n"
|
||||
);
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function runLoginAntigravity(opts) {
|
||||
try {
|
||||
await runAntigravityLogin({
|
||||
browser: opts.browser,
|
||||
timeout: opts.timeout,
|
||||
port: opts.port,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`\nLogin failed: ${err?.message || err}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerLogin(program) {
|
||||
const login = program
|
||||
.command("login")
|
||||
.description("Local OAuth helpers for remote OmniRoute installs (run on your own machine)");
|
||||
|
||||
login
|
||||
.command("antigravity")
|
||||
.description("Authorize Antigravity locally and print a credential blob to paste remotely")
|
||||
.option("--no-browser", "Do not auto-open the browser; print the URL instead")
|
||||
.option("--port <n>", "Fixed loopback port (default: OS-assigned)", (v) => parseInt(v, 10))
|
||||
.option("--timeout <ms>", "How long to wait for the callback", (v) => parseInt(v, 10), 300000)
|
||||
.action(runLoginAntigravity);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { registerMemory } from "./memory.mjs";
|
||||
import { registerSkills } from "./skills.mjs";
|
||||
import { registerAudit } from "./audit.mjs";
|
||||
import { registerOAuth } from "./oauth.mjs";
|
||||
import { registerLogin } from "./login.mjs";
|
||||
import { registerCloud } from "./cloud.mjs";
|
||||
import { registerEval } from "./eval.mjs";
|
||||
import { registerWebhooks } from "./webhooks.mjs";
|
||||
@@ -71,6 +70,7 @@ import { registerSetupCrush } from "./setup-crush.mjs";
|
||||
import { registerSetupGoose } from "./setup-goose.mjs";
|
||||
import { registerSetupQwen } from "./setup-qwen.mjs";
|
||||
import { registerSetupAider } from "./setup-aider.mjs";
|
||||
import { registerSetupGemini } from "./setup-gemini.mjs";
|
||||
import { registerConnect } from "./connect.mjs";
|
||||
import { registerContexts } from "./contexts.mjs";
|
||||
import { registerTokens } from "./tokens.mjs";
|
||||
@@ -83,7 +83,6 @@ export function registerCommands(program) {
|
||||
registerSkills(program);
|
||||
registerAudit(program);
|
||||
registerOAuth(program);
|
||||
registerLogin(program);
|
||||
registerCloud(program);
|
||||
registerEval(program);
|
||||
registerWebhooks(program);
|
||||
@@ -153,6 +152,7 @@ export function registerCommands(program) {
|
||||
registerSetupGoose(program);
|
||||
registerSetupQwen(program);
|
||||
registerSetupAider(program);
|
||||
registerSetupGemini(program);
|
||||
registerConnect(program);
|
||||
registerContexts(program);
|
||||
registerTokens(program);
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { platform, totalmem, hostname as osHostname } from "node:os";
|
||||
import { platform } from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
|
||||
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
|
||||
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
|
||||
import {
|
||||
resolveMaxOldSpaceMb,
|
||||
calibrateHeapFallbackMb,
|
||||
buildServerNodeOptions,
|
||||
buildNodeHeapArgs,
|
||||
} from "../../../scripts/build/runtime-env.mjs";
|
||||
import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "package.json"), "utf8"));
|
||||
|
||||
// URL scheme for the "OmniRoute is running" banner — flipped to https when
|
||||
// opt-in TLS (#5242) is active. Process-scoped: one `serve` run = one scheme.
|
||||
let urlScheme = "http";
|
||||
const ROOT = join(__dirname, "..", "..", "..");
|
||||
// The standalone bundle ships in `dist/` (since the build-output-isolation
|
||||
// refactor). Fall back to the legacy `app/` location so an upgrade over a
|
||||
@@ -47,24 +35,12 @@ export function registerServe(program) {
|
||||
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
|
||||
.option("--tray", t("serve.tray") || "Show system tray icon (desktop only)")
|
||||
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon")
|
||||
.option(
|
||||
"--tls-cert <path>",
|
||||
t("serve.tls_cert") ||
|
||||
"Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)"
|
||||
)
|
||||
.option(
|
||||
"--tls-key <path>",
|
||||
t("serve.tls_key") ||
|
||||
"Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await runServe(opts);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runServe(opts = {}) {
|
||||
const startedAt = performance.now();
|
||||
|
||||
const { isNativeBinaryCompatible } =
|
||||
await import("../../../scripts/build/native-binary-compat.mjs");
|
||||
const { getNodeRuntimeSupport, getNodeRuntimeWarning } =
|
||||
@@ -83,7 +59,6 @@ export async function runServe(opts = {}) {
|
||||
| |__| | | | | | | | | | | | \\ \\ (_) | |_| | || __/
|
||||
\\____/|_| |_| |_|_| |_|_|_| \\_\\___/ \\__,_|\\__\\___|
|
||||
\x1b[0m`);
|
||||
console.log(`\x1b[2m v${_pkg.version}\x1b[0m\n`);
|
||||
|
||||
const nodeSupport = getNodeRuntimeSupport();
|
||||
if (!nodeSupport.nodeCompatible) {
|
||||
@@ -151,18 +126,9 @@ export async function runServe(opts = {}) {
|
||||
|
||||
console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
|
||||
|
||||
// #5172/#5160/#5152: default the V8 heap to ~35% of physical RAM (clamped
|
||||
// [512, 4096]) instead of a fixed 512MB, which OOM-crashed boxes with plenty
|
||||
// of RAM under load. An explicit OMNIROUTE_MEMORY_MB still wins.
|
||||
const memoryLimit = resolveMaxOldSpaceMb(
|
||||
process.env.OMNIROUTE_MEMORY_MB,
|
||||
calibrateHeapFallbackMb(totalmem())
|
||||
);
|
||||
|
||||
// #5242: opt-in native HTTPS. CLI flags take precedence over env; the child
|
||||
// server (server-ws.mjs) reads these and terminates TLS on the same listener.
|
||||
const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT;
|
||||
const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY;
|
||||
const rawMemory = parseInt(process.env.OMNIROUTE_MEMORY_MB || "512", 10);
|
||||
const memoryLimit =
|
||||
Number.isFinite(rawMemory) && rawMemory >= 64 && rawMemory <= 16384 ? rawMemory : 512;
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
@@ -170,30 +136,11 @@ export async function runServe(opts = {}) {
|
||||
PORT: String(dashboardPort),
|
||||
DASHBOARD_PORT: String(dashboardPort),
|
||||
API_PORT: String(apiPort),
|
||||
// #6194: POSIX shells (bash/zsh) auto-set HOSTNAME to the machine name — the
|
||||
// .env loader (first-wins) can never override it. Ignore HOSTNAME when it
|
||||
// matches the OS-reported hostname (the auto-set signature). OMNIROUTE_SERVER_HOST
|
||||
// takes precedence; legacy HOSTNAME values that don't match os.hostname() are
|
||||
// still honoured for backward compatibility (e.g. Windows CMD/PowerShell users
|
||||
// who set HOSTNAME in .env where it is NOT auto-set).
|
||||
HOSTNAME:
|
||||
process.env.OMNIROUTE_SERVER_HOST ||
|
||||
(process.env.HOSTNAME !== osHostname() ? process.env.HOSTNAME : undefined) ||
|
||||
"0.0.0.0",
|
||||
HOSTNAME: "0.0.0.0",
|
||||
NODE_ENV: "production",
|
||||
// #5238: preserve a user-set NODE_OPTIONS (incl. their own
|
||||
// `--max-old-space-size=…`) instead of clobbering it with the calibrated
|
||||
// default — mirror the Electron/standalone launchers.
|
||||
NODE_OPTIONS: buildServerNodeOptions(process.env, memoryLimit),
|
||||
...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}),
|
||||
...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}),
|
||||
NODE_OPTIONS: `--max-old-space-size=${memoryLimit}`,
|
||||
};
|
||||
|
||||
// Validate the TLS pair up front so the operator sees a clear warning in the
|
||||
// CLI (the child re-validates authoritatively). Drives the banner scheme;
|
||||
// when null we fall through to identical plain-HTTP behavior as before.
|
||||
urlScheme = resolveTlsOptions(env) ? "https" : "http";
|
||||
|
||||
const isDaemon = opts.daemon === true;
|
||||
const useTray = opts.tray === true;
|
||||
|
||||
@@ -202,15 +149,7 @@ export async function runServe(opts = {}) {
|
||||
}
|
||||
|
||||
if (opts.noRecovery) {
|
||||
return runWithoutRecovery(
|
||||
serverJs,
|
||||
env,
|
||||
memoryLimit,
|
||||
dashboardPort,
|
||||
apiPort,
|
||||
noOpen,
|
||||
startedAt
|
||||
);
|
||||
return runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen);
|
||||
}
|
||||
|
||||
return runWithSupervisor(
|
||||
@@ -222,15 +161,12 @@ export async function runServe(opts = {}) {
|
||||
noOpen,
|
||||
opts.log === true,
|
||||
opts.maxRestarts ?? 2,
|
||||
startedAt,
|
||||
useTray
|
||||
);
|
||||
}
|
||||
|
||||
function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
|
||||
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
|
||||
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
|
||||
const server = spawn("node", [...buildNodeHeapArgs(process.env, memoryLimit), serverJs], {
|
||||
const server = spawn("node", [`--max-old-space-size=${memoryLimit}`, serverJs], {
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
stdio: "ignore",
|
||||
@@ -239,14 +175,12 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
|
||||
writePidFile("server", server.pid);
|
||||
server.unref();
|
||||
console.log(`\x1b[32m✔ OmniRoute started in background (PID: ${server.pid})\x1b[0m`);
|
||||
console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${dashboardPort}`);
|
||||
console.log(` \x1b[1mAPI Base:\x1b[0m ${urlScheme}://localhost:${apiPort}/v1`);
|
||||
console.log(` \x1b[1mDashboard:\x1b[0m http://localhost:${dashboardPort}`);
|
||||
console.log(` \x1b[1mAPI Base:\x1b[0m http://localhost:${apiPort}/v1`);
|
||||
}
|
||||
|
||||
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen, startedAt) {
|
||||
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
|
||||
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
|
||||
const server = spawn("node", [...buildNodeHeapArgs(process.env, memoryLimit), serverJs], {
|
||||
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen) {
|
||||
const server = spawn("node", [`--max-old-space-size=${memoryLimit}`, serverJs], {
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
stdio: "pipe",
|
||||
@@ -264,7 +198,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
|
||||
(text.includes("Ready") || text.includes("started") || text.includes("listening"))
|
||||
) {
|
||||
started = true;
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
onReady(dashboardPort, apiPort, noOpen);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -298,7 +232,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
|
||||
setTimeout(() => {
|
||||
if (!started) {
|
||||
started = true;
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
onReady(dashboardPort, apiPort, noOpen);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
@@ -312,7 +246,6 @@ async function runWithSupervisor(
|
||||
noOpen,
|
||||
showLog,
|
||||
maxRestarts,
|
||||
startedAt,
|
||||
useTray = false
|
||||
) {
|
||||
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
|
||||
@@ -350,36 +283,12 @@ async function runWithSupervisor(
|
||||
waitForServer(dashboardPort, 60000).then(async (up) => {
|
||||
if (up) {
|
||||
if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
} else {
|
||||
reportReadinessTimeout(dashboardPort, supervisor);
|
||||
onReady(dashboardPort, apiPort, noOpen);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// #6321: waitForServer resolving `false` used to fall through silently — the CLI
|
||||
// printed the banner + "⏳ Starting server..." and then produced ZERO further
|
||||
// output forever, even though the child process may well have crashed or be
|
||||
// stuck (issue reports show the server sometimes actually comes up later, or is
|
||||
// reachable directly while the CLI still looks hung). Surface a clear diagnostic
|
||||
// plus whatever stdout/stderr the child buffered instead of going silent.
|
||||
export function reportReadinessTimeout(dashboardPort, supervisor) {
|
||||
console.error(
|
||||
`\n\x1b[33m⚠ Server did not respond within 60s.\x1b[0m It may still be starting, or may` +
|
||||
` have failed silently.`
|
||||
);
|
||||
console.error(` Try: curl -I http://localhost:${dashboardPort}/api/monitoring/health`);
|
||||
console.error(` Or: rerun with \x1b[36m--log\x1b[0m to see live server output.\n`);
|
||||
|
||||
const recentLog = supervisor?.getRecentLog?.() ?? [];
|
||||
if (recentLog.length) {
|
||||
console.error("--- Recent server output ---");
|
||||
recentLog.forEach((l) => console.error(l));
|
||||
console.error("--- End recent output ---\n");
|
||||
}
|
||||
}
|
||||
|
||||
let _killTray = null;
|
||||
function killTrayIfActive() {
|
||||
if (_killTray) {
|
||||
@@ -395,8 +304,8 @@ async function maybeStartTray(port, apiPort, supervisor) {
|
||||
const { initTray, isTraySupported } = await import("../tray/index.mjs");
|
||||
if (!isTraySupported()) return;
|
||||
const { default: open } = await import("open").catch(() => ({ default: null }));
|
||||
const dashboardUrl = `${urlScheme}://localhost:${port}`;
|
||||
const tray = await initTray({
|
||||
const dashboardUrl = `http://localhost:${port}`;
|
||||
const tray = initTray({
|
||||
port,
|
||||
onQuit: () => {
|
||||
killTrayIfActive();
|
||||
@@ -412,23 +321,17 @@ async function maybeStartTray(port, apiPort, supervisor) {
|
||||
const { killTray } = await import("../tray/index.mjs");
|
||||
_killTray = killTray;
|
||||
}
|
||||
} catch (err) {
|
||||
// tray is optional — do not fail the server, but surface why it failed so
|
||||
// "--tray shows nothing" is diagnosable instead of silent (#4605).
|
||||
process.stderr.write(`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`);
|
||||
} catch {
|
||||
// tray is optional — do not fail the server
|
||||
}
|
||||
}
|
||||
|
||||
async function onReady(dashboardPort, apiPort, noOpen, startedAt) {
|
||||
const dashboardUrl = `${urlScheme}://localhost:${dashboardPort}`;
|
||||
const apiUrl = `${urlScheme}://localhost:${apiPort}`;
|
||||
const elapsed =
|
||||
typeof startedAt === "number" && Number.isFinite(startedAt)
|
||||
? ((performance.now() - startedAt) / 1000).toFixed(1)
|
||||
: "0.0";
|
||||
async function onReady(dashboardPort, apiPort, noOpen) {
|
||||
const dashboardUrl = `http://localhost:${dashboardPort}`;
|
||||
const apiUrl = `http://localhost:${apiPort}`;
|
||||
|
||||
console.log(`
|
||||
\x1b[32m✔ OmniRoute is running!\x1b[0m \x1b[2m(started in ${elapsed}s)\x1b[0m
|
||||
\x1b[32m✔ OmniRoute is running!\x1b[0m
|
||||
|
||||
\x1b[1m Dashboard:\x1b[0m ${dashboardUrl}
|
||||
\x1b[1m API Base:\x1b[0m ${apiUrl}/v1
|
||||
|
||||
@@ -20,11 +20,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import {
|
||||
categoriseModel,
|
||||
isCodexCompatibleTextModel,
|
||||
profileNameFromModelId,
|
||||
} from "./setup-codex.mjs";
|
||||
import { categoriseModel } from "./setup-codex.mjs";
|
||||
|
||||
/** Map a Codex-style effort to a Claude Code settings.json effortLevel. */
|
||||
function effortLevelFor(cfg) {
|
||||
@@ -33,17 +29,6 @@ function effortLevelFor(cfg) {
|
||||
return cfg.effort || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic profile for a live-catalog model that `categoriseModel()` doesn't
|
||||
* recognize (e.g. any provider added after the hardcoded glm/kimi/mimo/…
|
||||
* pattern list was written). Mirrors setup-codex.mjs's fallbackCodexProfile()
|
||||
* so setup-claude never silently produces zero profiles for a fresh catalog.
|
||||
*/
|
||||
export function fallbackClaudeProfile(modelId, model) {
|
||||
if (!isCodexCompatibleTextModel(model)) return null;
|
||||
return { name: profileNameFromModelId(modelId) };
|
||||
}
|
||||
|
||||
/** Build the settings.json content for one Claude Code profile. */
|
||||
export function buildProfileSettings(modelId, baseUrl, cfg) {
|
||||
const env = {
|
||||
@@ -65,84 +50,18 @@ export function buildProfileSettings(modelId, baseUrl, cfg) {
|
||||
return JSON.stringify(settings, null, 2) + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Claude Code profile files for a live model catalog. Shared by the
|
||||
* `setup-claude` CLI command and the post-model-sync auto-sync so both stay
|
||||
* behaviorally identical. Writes `<claudeHome>/profiles/<name>/settings.json`
|
||||
* (directory-per-profile); never touches the active/default Claude config.
|
||||
* @param {Array} models
|
||||
* @param {{claudeHome?:string, baseUrl:string, dryRun?:boolean, only?:string, log?:(line:string)=>void}} opts
|
||||
* @returns {Promise<{written:number, skipped:number, profiles:Array<{name:string, model:string, filePath:string}>}>}
|
||||
*/
|
||||
export async function syncClaudeProfilesFromModels(models, opts = {}) {
|
||||
const claudeHome = opts.claudeHome || join(os.homedir(), ".claude");
|
||||
const profilesRoot = join(claudeHome, "profiles");
|
||||
const baseUrl = opts.baseUrl;
|
||||
const dryRun = Boolean(opts.dryRun);
|
||||
// Injectable dry-run printer (#5959): under the node:test runner, a child
|
||||
// process writing multi-byte UTF-8 (the "──" box-drawing heading) to stdout
|
||||
// corrupts the runner's V8-serialized event stream ~50% of the time
|
||||
// ("Unable to deserialize cloned data due to invalid or unsupported
|
||||
// version"). Tests inject a collector; the CLI default stays console.log.
|
||||
const log = opts.log ?? console.log;
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
if (!dryRun && !existsSync(profilesRoot)) {
|
||||
mkdirSync(profilesRoot, { recursive: true });
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
const profiles = [];
|
||||
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : (m.id ?? "");
|
||||
if (!id) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cfg = categoriseModel(id) ?? fallbackClaudeProfile(id, m);
|
||||
if (!cfg) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dir = join(profilesRoot, cfg.name);
|
||||
const filePath = join(dir, "settings.json");
|
||||
const content = buildProfileSettings(id, baseUrl, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
log(`\n── [dry-run] ${filePath} ──`);
|
||||
log(content);
|
||||
} else {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
profiles.push({ name: cfg.name, model: id, filePath });
|
||||
written++;
|
||||
}
|
||||
|
||||
return { written, skipped, profiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{remote?:string, port?:string, apiKey?:string, claudeHome?:string, dryRun?:boolean, only?:string}} opts
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function runSetupClaudeCommand(opts = {}) {
|
||||
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
|
||||
const baseUrl = (opts.remote ?? `http://localhost:${port}`)
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/v1$/, "");
|
||||
const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
const apiKey = opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? "";
|
||||
const claudeHome = opts.claudeHome ?? opts["claude-home"] ?? join(os.homedir(), ".claude");
|
||||
const profilesRoot = join(claudeHome, "profiles");
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
printHeading("OmniRoute → Claude Code profile generator");
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
@@ -170,25 +89,42 @@ export async function runSetupClaudeCommand(opts = {}) {
|
||||
|
||||
printInfo(`Received ${models.length} models from ${baseUrl}`);
|
||||
|
||||
const { written, skipped, profiles } = await syncClaudeProfilesFromModels(models, {
|
||||
claudeHome,
|
||||
baseUrl,
|
||||
dryRun,
|
||||
only: opts.only,
|
||||
});
|
||||
if (!dryRun && !existsSync(profilesRoot)) {
|
||||
mkdirSync(profilesRoot, { recursive: true });
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
for (const profile of profiles) {
|
||||
printSuccess(` ✓ profiles/${profile.name}/settings.json (${profile.model})`);
|
||||
let written = 0;
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : m.id ?? "";
|
||||
if (!id) continue;
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
|
||||
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
|
||||
const dir = join(profilesRoot, cfg.name);
|
||||
const filePath = join(dir, "settings.json");
|
||||
const content = buildProfileSettings(id, baseUrl, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${filePath} ──`);
|
||||
console.log(content);
|
||||
} else {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
printSuccess(` ✓ profiles/${cfg.name}/settings.json (${id})`);
|
||||
}
|
||||
written++;
|
||||
}
|
||||
|
||||
const skipped = models.length - written;
|
||||
if (!dryRun) {
|
||||
console.log("");
|
||||
printSuccess(`${written} Claude Code profiles written to ${profilesRoot}`);
|
||||
if (skipped > 0) printInfo(`${skipped} models skipped (no matching profile pattern)`);
|
||||
console.log("\nTo use a profile:");
|
||||
console.log(" omniroute launch --profile <name> # e.g. omniroute launch --profile glm52");
|
||||
console.log(
|
||||
" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude (export ANTHROPIC_AUTH_TOKEN first)"
|
||||
);
|
||||
console.log(" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude (export ANTHROPIC_AUTH_TOKEN first)");
|
||||
} else {
|
||||
console.log(`\n[dry-run] ${written} profiles would be written (${skipped} skipped)`);
|
||||
}
|
||||
@@ -207,10 +143,7 @@ export function registerSetupClaude(program) {
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--claude-home <dir>", "Claude home dir (default: ~/.claude)")
|
||||
.option(
|
||||
"--only <patterns>",
|
||||
"Comma-separated substrings — only matching model IDs (e.g. glm,kimi)"
|
||||
)
|
||||
.option("--only <patterns>", "Comma-separated substrings — only matching model IDs (e.g. glm,kimi)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const exitCode = await runSetupClaudeCommand(opts);
|
||||
|
||||
@@ -39,83 +39,29 @@ export function categoriseModel(modelId) {
|
||||
{ re: /kmc\/kimi-k2\.6/, name: "kimi-k26", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5\.2-max/, name: "glm52max", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5\.2$/, name: "glm52", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{
|
||||
re: /opencode-go\/mimo-v2\.5-pro/,
|
||||
name: "mimo-pro",
|
||||
ctx: 131072,
|
||||
compact: 112000,
|
||||
toolLimit: 32768,
|
||||
},
|
||||
{
|
||||
re: /opencode-go\/qwen3\.7-plus/,
|
||||
name: "qwen37plus",
|
||||
ctx: 32768,
|
||||
compact: 28000,
|
||||
toolLimit: 16384,
|
||||
},
|
||||
{ re: /opencode-go\/mimo-v2\.5-pro/, name: "mimo-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /opencode-go\/qwen3\.7-plus/, name: "qwen37plus", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
// ── Good models (high effort) ─────────────────────────────────────────────
|
||||
const goodPatterns = [
|
||||
{
|
||||
re: /ollamacloud\/deepseek-v4-pro/,
|
||||
name: "deepseek-pro",
|
||||
ctx: 131072,
|
||||
compact: 112000,
|
||||
toolLimit: 32768,
|
||||
},
|
||||
{
|
||||
re: /opencode-go\/mimo-v2\.5$/,
|
||||
name: "mimo",
|
||||
ctx: 131072,
|
||||
compact: 112000,
|
||||
toolLimit: 32768,
|
||||
},
|
||||
{ re: /ollamacloud\/deepseek-v4-pro/, name: "deepseek-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /opencode-go\/mimo-v2\.5$/, name: "mimo", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
];
|
||||
|
||||
// ── Simple models (no effort) ─────────────────────────────────────────────
|
||||
const simplePatterns = [
|
||||
{ re: /ollamacloud\/gemma4:31b/, name: "gemma4", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
{
|
||||
re: /ollamacloud\/nemotron-3-super/,
|
||||
name: "nemotron",
|
||||
ctx: 32768,
|
||||
compact: 28000,
|
||||
toolLimit: 16384,
|
||||
},
|
||||
{
|
||||
re: /ollamacloud\/gpt-oss:20b/,
|
||||
name: "gptoss",
|
||||
ctx: 32768,
|
||||
compact: 28000,
|
||||
toolLimit: 16384,
|
||||
},
|
||||
{ re: /ollamacloud\/nemotron-3-super/, name: "nemotron", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
{ re: /ollamacloud\/gpt-oss:20b/, name: "gptoss", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
// ── Fast models (low effort) ──────────────────────────────────────────────
|
||||
const fastPatterns = [
|
||||
{
|
||||
re: /ollamacloud\/deepseek-v4-flash/,
|
||||
name: "deepseek-flash",
|
||||
ctx: 65536,
|
||||
compact: 56000,
|
||||
toolLimit: 16384,
|
||||
},
|
||||
{
|
||||
re: /ollamacloud\/gemini-3-flash/,
|
||||
name: "gemini-flash",
|
||||
ctx: 1000000,
|
||||
compact: 850000,
|
||||
toolLimit: 32768,
|
||||
},
|
||||
{ re: /ollamacloud\/deepseek-v4-flash/, name: "deepseek-flash", ctx: 65536, compact: 56000, toolLimit: 16384 },
|
||||
{ re: /ollamacloud\/gemini-3-flash/, name: "gemini-flash", ctx: 1000000, compact: 850000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5-turbo/, name: "glm5turbo", ctx: 131072, compact: 112000, toolLimit: 16384 },
|
||||
{
|
||||
re: /glm\/glm-4\.7-flash/,
|
||||
name: "glm47flash",
|
||||
ctx: 131072,
|
||||
compact: 112000,
|
||||
toolLimit: 16384,
|
||||
},
|
||||
{ re: /glm\/glm-4\.7-flash/, name: "glm47flash", ctx: 131072, compact: 112000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
for (const p of thinkingPatterns) {
|
||||
@@ -134,92 +80,6 @@ export function categoriseModel(modelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstPositiveNumber(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shortHash(value) {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ value.charCodeAt(i);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export function profileNameFromModelId(modelId) {
|
||||
const normalized = String(modelId)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const base = normalized || "model";
|
||||
if (base.length <= 96) return base;
|
||||
return `${base.slice(0, 84).replace(/-+$/g, "")}-${shortHash(base)}`;
|
||||
}
|
||||
|
||||
function hasAnyValue(values, patterns) {
|
||||
return values.some((value) => patterns.some((pattern) => pattern.test(value)));
|
||||
}
|
||||
|
||||
export function isCodexCompatibleTextModel(model) {
|
||||
if (typeof model === "string") return true;
|
||||
|
||||
const id = String(model?.id ?? "").toLowerCase();
|
||||
const type = String(model?.type ?? "").toLowerCase();
|
||||
const outputModalities = Array.isArray(model?.output_modalities)
|
||||
? model.output_modalities.map((value) => String(value).toLowerCase())
|
||||
: [];
|
||||
|
||||
if (type && !["chat", "text", "language", "llm", "model"].includes(type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const unsupportedPatterns = [
|
||||
/(^|[/_-])(image|img|video|veo|seedance|audio|speech|voice|tts|stt|whisper)([/_-]|$)/,
|
||||
/(^|[/_-])(embedding|embeddings|embed|rerank|moderation|transcription)([/_-]|$)/,
|
||||
];
|
||||
if (hasAnyValue([id, type], unsupportedPatterns)) return false;
|
||||
|
||||
const nonTextModalities = [/^(image|video|audio)$/];
|
||||
if (hasAnyValue(outputModalities, nonTextModalities)) return false;
|
||||
|
||||
if (outputModalities.length > 0 && !outputModalities.includes("text")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function fallbackCodexProfile(modelId, model) {
|
||||
if (!isCodexCompatibleTextModel(model)) return null;
|
||||
|
||||
const ctx =
|
||||
typeof model === "string"
|
||||
? 128000
|
||||
: (firstPositiveNumber(
|
||||
model.context_length,
|
||||
model.max_context_window_tokens,
|
||||
model.max_input_tokens
|
||||
) ?? 128000);
|
||||
const maxOutput =
|
||||
typeof model === "string"
|
||||
? null
|
||||
: firstPositiveNumber(model.max_output_tokens, model.output_token_limit);
|
||||
const toolLimit = Math.min(Math.max(maxOutput ?? 16384, 8192), 32768);
|
||||
|
||||
return {
|
||||
name: profileNameFromModelId(modelId),
|
||||
ctx,
|
||||
compact: Math.floor(ctx * 0.85),
|
||||
summary: false,
|
||||
toolLimit,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the TOML content for a single profile. */
|
||||
function buildProfileToml(modelId, cfg) {
|
||||
const lines = [
|
||||
@@ -245,52 +105,6 @@ function buildProfileToml(modelId, cfg) {
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
export async function syncCodexProfilesFromModels(models, opts = {}) {
|
||||
const codexHome = opts.codexHome || join(os.homedir(), ".codex");
|
||||
const dryRun = Boolean(opts.dryRun);
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
if (!dryRun && !existsSync(codexHome)) {
|
||||
mkdirSync(codexHome, { recursive: true });
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
const profiles = [];
|
||||
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : (m.id ?? "");
|
||||
if (!id) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cfg = categoriseModel(id) ?? fallbackCodexProfile(id, m);
|
||||
if (!cfg) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(codexHome, `${cfg.name}.config.toml`);
|
||||
const content = buildProfileToml(id, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${filePath} ──`);
|
||||
console.log(content);
|
||||
} else {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
profiles.push({ name: cfg.name, model: id, filePath });
|
||||
written++;
|
||||
}
|
||||
|
||||
return { written, skipped, profiles };
|
||||
}
|
||||
|
||||
// ── Command ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -332,17 +146,39 @@ export async function runSetupCodexCommand(opts = {}) {
|
||||
|
||||
printInfo(`Received ${models.length} models from ${baseUrl}`);
|
||||
|
||||
// ── Ensure codex home exists ──────────────────────────────────────────────
|
||||
if (!dryRun && !existsSync(codexHome)) {
|
||||
mkdirSync(codexHome, { recursive: true });
|
||||
}
|
||||
|
||||
// ── Generate profiles ─────────────────────────────────────────────────────
|
||||
const { written, skipped, profiles } = await syncCodexProfilesFromModels(models, {
|
||||
codexHome,
|
||||
dryRun,
|
||||
only: opts.only,
|
||||
});
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : (m.id ?? "");
|
||||
if (!id) continue;
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
|
||||
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
|
||||
const filePath = join(codexHome, `${cfg.name}.config.toml`);
|
||||
const content = buildProfileToml(id, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${filePath} ──`);
|
||||
console.log(content);
|
||||
} else {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
printSuccess(` ✓ ${cfg.name}.config.toml (${id})`);
|
||||
}
|
||||
written++;
|
||||
}
|
||||
|
||||
skipped = models.length - written;
|
||||
|
||||
if (!dryRun) {
|
||||
for (const profile of profiles) {
|
||||
printSuccess(` ✓ ${profile.name}.config.toml (${profile.model})`);
|
||||
}
|
||||
console.log("");
|
||||
printSuccess(`${written} profiles written to ${codexHome}`);
|
||||
if (skipped > 0) {
|
||||
@@ -374,7 +210,10 @@ export function registerSetupCodex(program) {
|
||||
"--api-key <key>",
|
||||
"OmniRoute API key for the remote instance (defaults to OMNIROUTE_API_KEY env var)"
|
||||
)
|
||||
.option("--codex-home <dir>", "Directory where profile files are written (default: ~/.codex)")
|
||||
.option(
|
||||
"--codex-home <dir>",
|
||||
"Directory where profile files are written (default: ~/.codex)"
|
||||
)
|
||||
.option(
|
||||
"--only <patterns>",
|
||||
"Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)"
|
||||
|
||||
148
bin/cli/commands/setup-gemini.mjs
Normal file
148
bin/cli/commands/setup-gemini.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* omniroute setup-gemini — point the Gemini CLI at OmniRoute's Gemini endpoint.
|
||||
*
|
||||
* The Gemini CLI is NOT OpenAI-compatible — it speaks the native Gemini API.
|
||||
* OmniRoute exposes a Gemini-native surface at /v1beta (e.g.
|
||||
* /v1beta/models/<model>:generateContent), so the CLI can target it via the
|
||||
* @google/genai SDK env `GOOGLE_GEMINI_BASE_URL` (ROOT — the SDK appends /v1beta)
|
||||
* + `GEMINI_API_KEY`. There is no settings.json key for the base URL, so this is
|
||||
* primarily an env recipe; we optionally write ~/.gemini/settings.json `model`.
|
||||
*
|
||||
* ⚠ Known Gemini CLI caveat: it may ignore GOOGLE_GEMINI_BASE_URL if a cached
|
||||
* Google login exists — run `gemini` logged-out / API-key-only for it to take.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripToRoot(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1beta") ? s.slice(0, -7) : s.endsWith("/v1") ? s.slice(0, -3) : s;
|
||||
}
|
||||
|
||||
/** Resolve GOOGLE_GEMINI_BASE_URL (ROOT — SDK appends /v1beta) + apiKey. */
|
||||
export function resolveGeminiTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: root, apiKey };
|
||||
}
|
||||
|
||||
/** The guaranteed env recipe (pure → testable). */
|
||||
export function buildGeminiRecipe({ baseUrl, model }) {
|
||||
return [
|
||||
`export GOOGLE_GEMINI_BASE_URL=${baseUrl}`,
|
||||
"export GEMINI_API_KEY=$OMNIROUTE_API_KEY",
|
||||
`export GEMINI_MODEL=${model}`,
|
||||
`gemini -p "reply OK" # or: gemini (interactive)`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Merge the model into ~/.gemini/settings.json (base URL is env-only). */
|
||||
export function buildGeminiSettings(existing, { model }) {
|
||||
const s = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
if (model) s.model = model;
|
||||
return s;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchGeminiModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/v1beta/models`, {
|
||||
headers: { "x-goog-api-key": apiKey || "" },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
return (body.models || []).map((m) => String(m.name || "").replace(/^models\//, "")).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupGeminiCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveGeminiTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".gemini", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Gemini CLI (native Gemini /v1beta endpoint)");
|
||||
printInfo(`GOOGLE_GEMINI_BASE_URL: ${baseUrl} (root — SDK appends /v1beta)`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchGeminiModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Gemini CLI");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id>.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${configPath} ── { "model": "${model}" }`);
|
||||
} else {
|
||||
const merged = buildGeminiSettings(readJson(configPath), { model });
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Wrote ${configPath} (model)`);
|
||||
}
|
||||
|
||||
printInfo("\nThe base URL is env-only for Gemini CLI — export these:");
|
||||
console.log(buildGeminiRecipe({ baseUrl, model }));
|
||||
printInfo("\n⚠ If Gemini CLI ignores the base URL, you have a cached Google login —");
|
||||
printInfo(" run logged-out (API-key only) so GOOGLE_GEMINI_BASE_URL takes effect.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupGemini(program) {
|
||||
program
|
||||
.command("setup-gemini")
|
||||
.description("Point the Gemini CLI at OmniRoute's native Gemini /v1beta endpoint (env recipe + settings model)")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Gemini CLI (required unless picked interactively)")
|
||||
.option("--config-path <path>", "settings.json path (default: ~/.gemini/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupGeminiCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* omniroute setup-qwen — configure Qwen Code (QwenLM/qwen-code) for OmniRoute.
|
||||
*
|
||||
* Qwen Code is a terminal AI agent with a file-based config at
|
||||
* Qwen Code is a terminal AI agent (gemini-cli fork) with a file-based config at
|
||||
* ~/.qwen/settings.json. For a custom OpenAI-compatible endpoint it uses a
|
||||
* `modelProviders` entry with authType "openai", baseUrl WITH /v1, and an
|
||||
* `envKey` naming the env var holding the key (secret stays in the env, never the
|
||||
@@ -47,9 +47,7 @@ export function resolveQwenTarget(opts = {}) {
|
||||
/** Merge the OmniRoute modelProvider into Qwen's settings.json (preserve rest). */
|
||||
export function buildQwenSettings(existing, { baseUrl, model }) {
|
||||
const s = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
const providers = Array.isArray(s.modelProviders)
|
||||
? s.modelProviders.filter((p) => p?.id !== "omniroute")
|
||||
: [];
|
||||
const providers = Array.isArray(s.modelProviders) ? s.modelProviders.filter((p) => p?.id !== "omniroute") : [];
|
||||
providers.push({
|
||||
id: "omniroute",
|
||||
name: "OmniRoute",
|
||||
@@ -84,7 +82,7 @@ async function fetchModelIds(baseUrl, apiKey) {
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -94,8 +92,7 @@ async function fetchModelIds(baseUrl, apiKey) {
|
||||
export async function runSetupQwenCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveQwenTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath =
|
||||
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".qwen", "settings.json");
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".qwen", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Qwen Code (openai-compatible)");
|
||||
printInfo(`baseUrl: ${baseUrl}`);
|
||||
@@ -129,9 +126,7 @@ export async function runSetupQwenCommand(opts = {}) {
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath}`);
|
||||
}
|
||||
printInfo(
|
||||
"\nProvide the key (settings reference OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."
|
||||
);
|
||||
printInfo("\nProvide the key (settings reference OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=...");
|
||||
printInfo('Then run: qwen (or headless: qwen -p "reply OK")');
|
||||
return 0;
|
||||
}
|
||||
@@ -139,9 +134,7 @@ export async function runSetupQwenCommand(opts = {}) {
|
||||
export function registerSetupQwen(program) {
|
||||
program
|
||||
.command("setup-qwen")
|
||||
.description(
|
||||
"Configure Qwen Code for OmniRoute: write ~/.qwen/settings.json (openai modelProvider)"
|
||||
)
|
||||
.description("Configure Qwen Code for OmniRoute: write ~/.qwen/settings.json (openai modelProvider)")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
|
||||
@@ -241,9 +241,7 @@
|
||||
"no_recovery": "Disable auto-restart on crash (debugging mode)",
|
||||
"max_restarts": "Max crash restarts within 30s before giving up (default: 2)",
|
||||
"tray": "Show system tray icon (desktop only, opt-in)",
|
||||
"no_tray": "Disable system tray icon",
|
||||
"tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
|
||||
"no_tray": "Disable system tray icon"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,6 @@ function inferSchema(sample) {
|
||||
function formatCell(v, col) {
|
||||
if (v == null) return "";
|
||||
if (col.formatter) return col.formatter(v);
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { spawnSync } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
|
||||
const BETTER_SQLITE3_VERSION = "12.10.1";
|
||||
const BETTER_SQLITE3_VERSION = "12.9.0";
|
||||
|
||||
function runtimeDir() {
|
||||
return join(resolveDataDir(), "runtime");
|
||||
@@ -52,31 +52,6 @@ export function hasModule(name) {
|
||||
return existsSync(join(runtimeModules(), name, "package.json"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether a native addon (.node) file can actually be dlopen'd by the Node runtime that
|
||||
* is going to load it. Runs in a throwaway subprocess so a real ABI mismatch (which can segfault
|
||||
* the process instead of throwing) never takes down the caller — only the probe subprocess.
|
||||
*/
|
||||
function probeNativeBinaryLoadable(binary) {
|
||||
try {
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
"try { require(process.argv[1]); process.exit(0); } catch (e) { process.exit(1); }",
|
||||
binary,
|
||||
],
|
||||
{ timeout: 10_000, stdio: "ignore" }
|
||||
);
|
||||
// status === 0 means require() (and therefore dlopen) succeeded. Anything else — a thrown
|
||||
// ERR_DLOPEN_FAILED/NODE_MODULE_VERSION mismatch (status 1) or a crash (status null with a
|
||||
// signal, e.g. SIGSEGV) — means the binary is not safe to load.
|
||||
return res.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBetterSqliteBinaryValid() {
|
||||
const binary = join(
|
||||
runtimeModules(),
|
||||
@@ -93,18 +68,10 @@ export function isBetterSqliteBinaryValid() {
|
||||
closeSync(fd);
|
||||
const magic = buf.toString("hex");
|
||||
const os = platform();
|
||||
let formatOk;
|
||||
if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF
|
||||
else if (os === "darwin")
|
||||
formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
|
||||
else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ
|
||||
else formatOk = true;
|
||||
if (!formatOk) return false;
|
||||
// File-format magic bytes alone do not guarantee the binary was built for the Node ABI
|
||||
// (NODE_MODULE_VERSION) that will load it — a stale/foreign-ABI binary passes the header
|
||||
// check and then crashes (segfault) on load instead of triggering a rebuild. Actually
|
||||
// attempt to load it, isolated in a subprocess.
|
||||
return probeNativeBinaryLoadable(binary);
|
||||
if (os === "linux") return magic.startsWith("7f454c46"); // ELF
|
||||
if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
|
||||
if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -112,18 +79,7 @@ export function isBetterSqliteBinaryValid() {
|
||||
|
||||
export function npmInstallRuntime(pkgs, opts = {}) {
|
||||
const cwd = ensureRuntimeDir();
|
||||
// Persist to the runtime package.json (exact version) instead of --no-save so a later
|
||||
// install of a sibling runtime dep (e.g. systray2 from trayRuntime.ts, which writes to the
|
||||
// same runtime dir) does not prune this package as "extraneous" — that pruning otherwise
|
||||
// reproduces "No SQLite driver available" after a tray install removes better-sqlite3.
|
||||
const npmArgs = [
|
||||
"install",
|
||||
...pkgs,
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--prefer-online",
|
||||
"--save-exact",
|
||||
];
|
||||
const npmArgs = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online", "--no-save"];
|
||||
// On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly
|
||||
// so we never set shell:true (which would propagate env and enable injection).
|
||||
const isWin = platform() === "win32";
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
computeRestartDelayMs,
|
||||
waitUntilPortFree,
|
||||
} from "./supervisorPolicy.mjs";
|
||||
import { buildNodeHeapArgs } from "../../../scripts/build/runtime-env.mjs";
|
||||
|
||||
const CRASH_LOG_LINES = 50;
|
||||
|
||||
@@ -31,36 +30,22 @@ export class ServerSupervisor {
|
||||
this.crashLog = [];
|
||||
|
||||
const showLog = process.env.OMNIROUTE_SHOW_LOG === "1";
|
||||
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
|
||||
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value). The
|
||||
// calibrated heap is already carried by env.NODE_OPTIONS either way.
|
||||
const heapArgs = buildNodeHeapArgs(process.env, this.memoryLimit);
|
||||
// #6321: stdout used to be discarded (`"ignore"`) whenever `--log`/OMNIROUTE_SHOW_LOG
|
||||
// wasn't set (the default) — any debug/pino output written to stdout vanished
|
||||
// silently, so a boot that never becomes ready looked like a dead hang with zero
|
||||
// output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside
|
||||
// stderr so a readiness timeout can surface what the child actually printed.
|
||||
this.child = spawn("node", [...heapArgs, this.serverPath], {
|
||||
this.child = spawn("node", [`--max-old-space-size=${this.memoryLimit}`, this.serverPath], {
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
|
||||
writePidFile("server", this.child.pid);
|
||||
|
||||
const bufferOutput = (data) => {
|
||||
const lines = data.toString().split("\n").filter(Boolean);
|
||||
this.crashLog.push(...lines);
|
||||
if (this.crashLog.length > CRASH_LOG_LINES) {
|
||||
this.crashLog = this.crashLog.slice(-CRASH_LOG_LINES);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.child.stdout) {
|
||||
this.child.stdout.on("data", bufferOutput);
|
||||
}
|
||||
if (this.child.stderr) {
|
||||
this.child.stderr.on("data", bufferOutput);
|
||||
this.child.stderr.on("data", (data) => {
|
||||
const lines = data.toString().split("\n").filter(Boolean);
|
||||
this.crashLog.push(...lines);
|
||||
if (this.crashLog.length > CRASH_LOG_LINES) {
|
||||
this.crashLog = this.crashLog.slice(-CRASH_LOG_LINES);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.child.on("error", (err) => this.handleExit(-1, err));
|
||||
@@ -117,12 +102,6 @@ export class ServerSupervisor {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// #6321: exposes the buffered stdout+stderr lines so a caller (e.g. a readiness
|
||||
// timeout) can print what the child actually said instead of silence.
|
||||
getRecentLog() {
|
||||
return [...this.crashLog];
|
||||
}
|
||||
|
||||
dumpCrashLog() {
|
||||
console.error("\n--- Server crash log ---");
|
||||
this.crashLog.forEach((l) => console.error(l));
|
||||
|
||||
@@ -6,7 +6,7 @@ import { pathToFileURL } from "node:url";
|
||||
import { validateBinaryMagic, platformBinaryLabel } from "./magicBytes.mjs";
|
||||
|
||||
const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime");
|
||||
const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.10.1";
|
||||
const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.6.2";
|
||||
|
||||
let resolvedCached = null;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
@@ -6,43 +6,7 @@ import { execSync } from "node:child_process";
|
||||
const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime");
|
||||
// systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime,
|
||||
// not in dependencies, to avoid npm install overhead for users who don't use --tray.
|
||||
//
|
||||
// Pin: stay on the 2.x line. The original `systray@1.0.5` package bundles a 2017
|
||||
// x86_64 Go binary whose Mach-O headers modern dyld (macOS 14+) rejects, so the
|
||||
// tray silently fails to register on Apple Silicon. systray2 2.x ships newer
|
||||
// getlantern/systray-portable binaries that work under Rosetta. Inherited from
|
||||
// upstream decolua/9router#1080.
|
||||
export const SYSTRAY_PACKAGE = "systray2";
|
||||
export const SYSTRAY_VERSION = "2.1.4";
|
||||
const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`;
|
||||
|
||||
export function resolveSystrayBinName(platform: NodeJS.Platform): string | null {
|
||||
if (platform === "win32") return null;
|
||||
if (platform === "darwin") return "tray_darwin_release";
|
||||
return "tray_linux_release";
|
||||
}
|
||||
|
||||
export interface ChmodResult {
|
||||
changed: boolean;
|
||||
reason?: "win32-skip" | "missing" | "chmod-failed";
|
||||
}
|
||||
|
||||
// systray2's npm tarball sometimes ships the bundled Go binary without the
|
||||
// executable bit set on macOS/Linux, causing spawn() to fail with EACCES.
|
||||
// Set +x best-effort so the tray actually starts. Inherited from
|
||||
// upstream decolua/9router#1080.
|
||||
export function chmodSystrayBinAt(runtimeRoot: string, platform: NodeJS.Platform): ChmodResult {
|
||||
const binName = resolveSystrayBinName(platform);
|
||||
if (!binName) return { changed: false, reason: "win32-skip" };
|
||||
const binPath = join(runtimeRoot, "node_modules", SYSTRAY_PACKAGE, "traybin", binName);
|
||||
if (!existsSync(binPath)) return { changed: false, reason: "missing" };
|
||||
try {
|
||||
chmodSync(binPath, 0o755);
|
||||
return { changed: true };
|
||||
} catch {
|
||||
return { changed: false, reason: "chmod-failed" };
|
||||
}
|
||||
}
|
||||
const SYSTRAY_VERSION = "systray2@1.4.5";
|
||||
|
||||
export async function loadSystray(): Promise<(new (...args: unknown[]) => unknown) | null> {
|
||||
if (process.platform === "win32") return null; // Windows uses tray.ps1 instead
|
||||
@@ -51,21 +15,15 @@ export async function loadSystray(): Promise<(new (...args: unknown[]) => unknow
|
||||
try {
|
||||
installSystray();
|
||||
} catch (err) {
|
||||
// Surface failures to stderr instead of staying silent — anyone hitting
|
||||
// a tray problem otherwise has zero diagnostic. (PR #1080)
|
||||
console.warn(`[omniroute] tray runtime install failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Best-effort: ensure the bundled Go binary is executable. Some npm tarballs
|
||||
// drop the +x bit on extraction (observed on macOS).
|
||||
chmodSystrayBinAt(RUNTIME_DIR, process.platform);
|
||||
try {
|
||||
const modPath = join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE);
|
||||
const modPath = join(RUNTIME_DIR, "node_modules", "systray2");
|
||||
const mod = await import(modPath);
|
||||
return (mod.default ?? mod.SysTray ?? mod) as (new (...args: unknown[]) => unknown) | null;
|
||||
} catch (err) {
|
||||
console.warn(`[omniroute] tray runtime import failed: ${(err as Error).message}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -79,16 +37,12 @@ function ensureRuntimeDir(): void {
|
||||
}
|
||||
|
||||
function isInstalled(): boolean {
|
||||
return existsSync(join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE, "package.json"));
|
||||
return existsSync(join(RUNTIME_DIR, "node_modules", "systray2", "package.json"));
|
||||
}
|
||||
|
||||
function installSystray(): void {
|
||||
// --save-exact persists systray2 to the runtime package.json so installing it does not
|
||||
// prune a sibling runtime dep (e.g. better-sqlite3 from nativeDeps.mjs, which writes to the
|
||||
// same runtime dir) as "extraneous", and so the tray dep survives a later sibling install.
|
||||
// Without it, a sibling install reproduces "No SQLite driver available".
|
||||
execSync(
|
||||
`npm install --prefix "${RUNTIME_DIR}" ${SYSTRAY_SPEC} --no-audit --no-fund --save-exact --silent`,
|
||||
`npm install --prefix "${RUNTIME_DIR}" ${SYSTRAY_VERSION} --no-audit --no-fund --silent`,
|
||||
{ stdio: ["ignore", "ignore", "pipe"], timeout: 120_000 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,64 +204,6 @@ export function isAutostartEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure parse of `launchctl list <label>` output: true only when the agent's
|
||||
* "PID" line matches the given process id. launchctl prints the running PID for
|
||||
* a managed Aqua agent; a loaded-but-not-running agent omits the key entirely.
|
||||
*
|
||||
* Exported for unit testing — the darwin branch can't execute on the Linux CI.
|
||||
*/
|
||||
export function parseAgentSelfFromLaunchctl(output, pid) {
|
||||
if (!output) return false;
|
||||
const match = output.match(/"PID"\s*=\s*(\d+)/);
|
||||
return !!(match && parseInt(match[1], 10) === pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `runList()` (which should run `launchctl list <label>`) succeeds,
|
||||
* i.e. launchd actually recognizes the agent. A plist sitting on disk that
|
||||
* launchd never loaded must NOT count as enabled — checking file existence
|
||||
* alone makes the tray menu lie ("✓ Enabled" while launchd has the agent in a
|
||||
* failed state or never loaded it).
|
||||
*
|
||||
* `runList` is injectable so the parse/branch logic is testable without a real
|
||||
* launchctl on the (Linux) CI runner.
|
||||
*/
|
||||
export function isLaunchdAgentLoaded(runList) {
|
||||
try {
|
||||
runList();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current Node process IS the running instance launchd is
|
||||
* managing under our agent label.
|
||||
*
|
||||
* `launchctl unload`/`load -w` for a user-domain agent sends SIGTERM to the
|
||||
* running process. When the running OmniRoute cli was itself spawned by the
|
||||
* autostart launchd agent (autostart was enabled, then the machine rebooted,
|
||||
* then the user clicked the tray "Disable Autostart" item), an unload would
|
||||
* kill the very process executing the click handler — the tray icon would
|
||||
* silently disappear instead of the menu label flipping. Enable/disable skip
|
||||
* launchctl in that case; the on-disk plist change is what matters for the next
|
||||
* login, and launchd already has us loaded under our own PID.
|
||||
*/
|
||||
function isAgentSelfMac() {
|
||||
try {
|
||||
const output = execFileSync("launchctl", ["list", APP_LABEL], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 3000,
|
||||
});
|
||||
return parseAgentSelfFromLaunchctl(output, process.pid);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function enableMac() {
|
||||
const plistDir = join(homedir(), "Library", "LaunchAgents");
|
||||
mkdirSync(plistDir, { recursive: true });
|
||||
@@ -283,10 +225,6 @@ function enableMac() {
|
||||
<key>KeepAlive</key><false/>
|
||||
</dict></plist>`;
|
||||
writeFileSync(plistPath, plist, { mode: 0o644 });
|
||||
// If we're already the running agent, launchctl load/unload would SIGTERM us.
|
||||
// The plist is updated on disk and launchd already has us loaded under our own
|
||||
// PID — nothing more to do for the current session.
|
||||
if (isAgentSelfMac()) return existsSync(plistPath);
|
||||
try {
|
||||
execSync("launchctl load -w " + JSON.stringify(plistPath), { stdio: "ignore" });
|
||||
} catch {}
|
||||
@@ -295,15 +233,9 @@ function enableMac() {
|
||||
|
||||
function disableMac() {
|
||||
const plistPath = join(homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
|
||||
// Don't kill ourselves: when the current process is the running agent,
|
||||
// `launchctl unload` sends SIGTERM and a user clicking "Disable Autostart"
|
||||
// from the tray would lose the tray icon instead of just flipping the label.
|
||||
// Removing the plist file is enough to stop the agent at the next login.
|
||||
if (!isAgentSelfMac()) {
|
||||
try {
|
||||
execSync("launchctl unload -w " + JSON.stringify(plistPath), { stdio: "ignore" });
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
execSync("launchctl unload -w " + JSON.stringify(plistPath), { stdio: "ignore" });
|
||||
} catch {}
|
||||
try {
|
||||
unlinkSync(plistPath);
|
||||
} catch {}
|
||||
@@ -311,16 +243,7 @@ function disableMac() {
|
||||
}
|
||||
|
||||
function isEnabledMac() {
|
||||
const plistPath = join(homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`);
|
||||
if (!existsSync(plistPath)) return false;
|
||||
// The plist file existing is not enough — launchd must actually recognize the
|
||||
// agent, otherwise the tray menu reports "Enabled" for an inert/failed agent.
|
||||
return isLaunchdAgentLoaded(() =>
|
||||
execFileSync("launchctl", ["list", APP_LABEL], {
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
timeout: 3000,
|
||||
})
|
||||
);
|
||||
return existsSync(join(homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`));
|
||||
}
|
||||
|
||||
function enableWin() {
|
||||
|
||||
@@ -5,12 +5,10 @@ let active = null;
|
||||
|
||||
export { isTraySupported };
|
||||
|
||||
export async function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
|
||||
export function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
|
||||
if (!isTraySupported()) return null;
|
||||
const ctx = { port, onQuit, onOpenDashboard, onShowLogs };
|
||||
// initSystrayUnix is async: it lazily installs/loads systray2 from the runtime
|
||||
// dir (trayRuntime.ts) rather than from node_modules. (#4605)
|
||||
active = process.platform === "win32" ? initWinTray(ctx) : await initSystrayUnix(ctx);
|
||||
active = process.platform === "win32" ? initWinTray(ctx) : initSystrayUnix(ctx);
|
||||
return active;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,10 +124,6 @@ async function initUnixTray(options: TrayOptions): Promise<TrayInstance | null>
|
||||
const systray = new SysTray({
|
||||
menu: {
|
||||
icon: getIconBase64(),
|
||||
// isTemplateIcon: false on darwin — the bundled icon.png is a full-color
|
||||
// RGBA logo; template mode would render it as a solid white square
|
||||
// because macOS template icons only use the alpha channel. (PR #1080)
|
||||
isTemplateIcon: false,
|
||||
title: "OmniRoute",
|
||||
tooltip: `OmniRoute :${options.port}`,
|
||||
items: menuItems.map((it) => ({
|
||||
@@ -179,8 +175,6 @@ async function initUnixTray(options: TrayOptions): Promise<TrayInstance | null>
|
||||
setTooltip: () => {
|
||||
/* systray2 does not support runtime tooltip change */
|
||||
},
|
||||
// Pass false so systray2's kill does NOT call process.exit(0) before the
|
||||
// rest of cleanup (server SIGKILL, MITM/tunnel cleanup) runs. (PR #1080)
|
||||
destroy: () => systray.kill(false),
|
||||
destroy: () => systray.kill(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,31 +14,30 @@ export function isTraySupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// systray2 is NOT a static dependency — it is lazily installed into
|
||||
// ~/.omniroute/runtime by trayRuntime.ts (loadSystray). The previous inline
|
||||
// loader called `require("module")`, which throws `ReferenceError: require is
|
||||
// not defined` in this ESM file (package "type":"module"); the throw was
|
||||
// silently swallowed, so the tray never appeared on macOS/Linux with no error
|
||||
// printed (#4605, regressed in v3.8.34). Delegate to the runtime loader, which
|
||||
// resolves systray2 from the runtime dir and surfaces install/import failures.
|
||||
async function loadSystray2() {
|
||||
const { loadSystray } = await import("../runtime/trayRuntime.ts");
|
||||
return loadSystray();
|
||||
function loadSystray2() {
|
||||
const candidates = [
|
||||
() => {
|
||||
const { createRequire } = require("module");
|
||||
const req = createRequire(import.meta.url);
|
||||
return req("systray2").default;
|
||||
},
|
||||
];
|
||||
for (const attempt of candidates) {
|
||||
try {
|
||||
return attempt();
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getIconBase64() {
|
||||
// Icon ships at bin/cli/tray/icon.png — the previous "icons/icon.png" path
|
||||
// never existed, so the tray was created with an empty icon (#4605).
|
||||
const iconPath = join(__dirname, "icon.png");
|
||||
const iconPath = join(__dirname, "icons", "icon.png");
|
||||
if (existsSync(iconPath)) return readFileSync(iconPath).toString("base64");
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function initSystrayUnix(
|
||||
{ port, onQuit, onOpenDashboard, onShowLogs },
|
||||
loadCtor = loadSystray2
|
||||
) {
|
||||
const SysTray = await loadCtor();
|
||||
export function initSystrayUnix({ port, onQuit, onOpenDashboard, onShowLogs }) {
|
||||
const SysTray = loadSystray2();
|
||||
if (!SysTray) return null;
|
||||
|
||||
const autostartEnabled = isAutostartEnabled();
|
||||
@@ -58,10 +57,7 @@ export async function initSystrayUnix(
|
||||
tray = new SysTray({
|
||||
menu: {
|
||||
icon: getIconBase64(),
|
||||
// isTemplateIcon must be false: icon.png is a full-color RGBA logo, and
|
||||
// macOS template mode uses only the alpha channel → a solid white square
|
||||
// (the icon looked "missing" even when the tray loaded). (PR #1080)
|
||||
isTemplateIcon: false,
|
||||
isTemplateIcon: process.platform === "darwin",
|
||||
title: "",
|
||||
tooltip: `OmniRoute — port ${port}`,
|
||||
items,
|
||||
@@ -104,32 +100,8 @@ export async function initSystrayUnix(
|
||||
return tray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Go systray2 child subprocess PID from a tray instance.
|
||||
* systray2 exposes the spawned binary either as the `_process` field or via a
|
||||
* `process()` accessor depending on version. Returns the numeric PID or null.
|
||||
*/
|
||||
export function getSystrayChildPid(tray) {
|
||||
if (!tray) return null;
|
||||
try {
|
||||
const proc = tray._process || (typeof tray.process === "function" ? tray.process() : null);
|
||||
if (proc && typeof proc.pid === "number") return proc.pid;
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function killSystrayUnix(tray) {
|
||||
try {
|
||||
// systray2.kill(false) closes the IPC channel but leaves the Go tray binary
|
||||
// subprocess running, which keeps an orphan NSStatusItem on macOS and blocks
|
||||
// a freshly spawned tray (e.g. on respawn / hide-to-tray) from registering.
|
||||
// SIGKILL the child PID directly first, then close IPC.
|
||||
const pid = getSystrayChildPid(tray);
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
tray.kill(false);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user