mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-05 22:42:29 +03:00
Compare commits
11 Commits
fix/releas
...
fix/sec-ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d29829b8c4 | ||
|
|
f1b4a3d93e | ||
|
|
ad15811e1a | ||
|
|
a03cb03110 | ||
|
|
81550a8629 | ||
|
|
4f5b647011 | ||
|
|
50f5cecb80 | ||
|
|
60060a6dca | ||
|
|
14078b2f2b | ||
|
|
38a2f8ba4d | ||
|
|
c46f048ca2 |
396
.env.example
396
.env.example
@@ -45,21 +45,10 @@ INITIAL_PASSWORD=CHANGEME
|
||||
# executor's on-disk thread-sticky session cache. Leave unset to rely on DATA_DIR.
|
||||
# OMNIROUTE_DATA_DIR=/var/lib/omniroute
|
||||
|
||||
# Directory the runtime plugin scanner reads, overriding the home-derived default (#11827).
|
||||
# Used by: src/lib/plugins/scanner.ts — getDefaultPluginDir(); it is also the root the
|
||||
# plugin manager installs into. Set it in Docker/K8s to point straight at the bind-mounted
|
||||
# plugin tree, instead of moving HOME (which changes every other HOME-relative behaviour)
|
||||
# just to relocate the scan path. Unset = <HOME>/.omniroute/plugins, and
|
||||
# /tmp/.omniroute/plugins when the process exports no home at all.
|
||||
# Distinct from the CLI-only variable in section 9 that points the omniroute-cmd-* command
|
||||
# loader (bin/cli/plugins.mjs) at a package tree — this one drives the server-side scanner.
|
||||
# OMNIROUTE_PLUGINS_DIR=/opt/omniroute/plugins
|
||||
|
||||
# Escape hatch for the test/eval DATA_DIR guard (#10428). A test or node eval/print
|
||||
# probe (-e/--eval/-p/--print, including --eval=/--print=) that never chose a DATA_DIR
|
||||
# is redirected to a throwaway temp dir so it cannot open the operator's real database.
|
||||
# Set to 1 only for a deliberate run against the real DATA_DIR — never for CI.
|
||||
# Used by: src/lib/dataPaths.ts
|
||||
# Escape hatch for the test-context DATA_DIR guard (#10428). A test run that never
|
||||
# chose a DATA_DIR is redirected to a throwaway temp dir so it cannot open the
|
||||
# operator's real database. Set to 1 only for a deliberate run against the real
|
||||
# DATA_DIR — never for CI. Used by: src/lib/dataPaths.ts
|
||||
# OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1
|
||||
|
||||
# Build provenance (#10427). OMNIROUTE_BUILD_SHA lets a container inject the artifact's git
|
||||
@@ -76,17 +65,6 @@ INITIAL_PASSWORD=CHANGEME
|
||||
# OMNIROUTE_RELEASE_REF=origin/main
|
||||
# OMNIROUTE_ALLOW_CANARY_BUILD=1
|
||||
|
||||
# Build-phase signal (#10060). Set to 1 by scripts/build/build-next-isolated.mjs and
|
||||
# inherited by every spawned build worker so the DB layer returns a no-op stub instead
|
||||
# of loading the native better-sqlite3 addon (which aborts the worker on exit).
|
||||
# Never set this for the running server. Used by: src/lib/buildPhase.ts, src/lib/db/core.ts
|
||||
# OMNIROUTE_BUILDING=1
|
||||
|
||||
# Skip the optional native-dependency prebuild check for exotic vendored trees.
|
||||
# This does not make a missing dependency buildable. Used by: scripts/check/check-native-deps.mjs
|
||||
# Default: 0 | Set to 1 only when native dependencies are supplied out of band.
|
||||
# OMNIROUTE_SKIP_NATIVE_DEP_CHECK=0
|
||||
|
||||
# Encryption key for SQLite database encryption at rest.
|
||||
# Used by: src/lib/db/encryption.ts — encrypts the entire SQLite database.
|
||||
# Generate: openssl rand -hex 32 | Leave empty to disable DB encryption.
|
||||
@@ -97,11 +75,9 @@ STORAGE_ENCRYPTION_KEY=
|
||||
# Default: v1 | Increment when rotating STORAGE_ENCRYPTION_KEY.
|
||||
STORAGE_ENCRYPTION_KEY_VERSION=v1
|
||||
|
||||
# Routine/pre-write SQLite backups.
|
||||
# Used by: src/lib/db/backup.ts. Set true only when those backups are managed externally.
|
||||
# This never disables the migration runner's mandatory, content-addressed safety snapshot
|
||||
# or its mass-migration guard for an existing persistent database.
|
||||
# Default: false (routine backups enabled).
|
||||
# Automatic SQLite backup on startup.
|
||||
# Used by: src/lib/db/backup.ts — creates a timestamped backup before migrations.
|
||||
# Default: false (backups enabled) | Set true to skip backup on every restart.
|
||||
DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
|
||||
# ── Redis (Rate Limiting) ──
|
||||
@@ -111,10 +87,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
# Used by: src/shared/utils/rateLimiter.ts
|
||||
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
# Namespace prefix for ALL OmniRoute Redis keys (rate limiter + auth cache +
|
||||
# quota store). Prevents key collisions when OmniRoute shares a Redis instance
|
||||
# with other apps (e.g. on 127.0.0.1:6379). Default when unset: omniroute:
|
||||
# REDIS_KEY_PREFIX=omniroute:
|
||||
# Host interface docker-compose publishes the Redis sidecar on.
|
||||
# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT
|
||||
# `requirepass`, and app containers reach it over the compose network
|
||||
@@ -243,7 +215,7 @@ PORT=20128
|
||||
# Used by: src/app/api/v1/relay/chat/completions/route.ts
|
||||
# RELAY_IP_PER_MINUTE=30
|
||||
|
||||
# Bundler selection for `npm run dev` and `npm run build`. Set to 0 to fall back to webpack.
|
||||
# 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"
|
||||
@@ -253,9 +225,8 @@ PORT=20128
|
||||
# /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 (scripts/build/build-next-isolated.mjs)
|
||||
# reads the same flag: Turbopack by default, 0 builds with webpack (`npm run
|
||||
# build:contributor` sets it for you).
|
||||
# this 60+ route app. The production build still uses webpack (build pipeline is
|
||||
# unaffected by this dev-only flag).
|
||||
OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
# Disable systemd sd_notify (Type=notify / WatchdogSec=) even when running
|
||||
@@ -401,8 +372,9 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# NO_LOG_API_KEY_IDS=key_abc123,key_def456
|
||||
|
||||
# Fallback per-day request budget applied to API keys whose `rate_limits`
|
||||
# column is null. Default (unset/empty) is unlimited (no implicit caps).
|
||||
# Malformed values preserve the legacy 1000/day, 5000/week, 20000/month windows.
|
||||
# column is null. Default (unset/empty/malformed) preserves the legacy
|
||||
# 1000/day, 5000/week, 20000/month windows so existing deployments do not
|
||||
# silently lose rate limiting on upgrade.
|
||||
# Set explicitly to "0" to opt out entirely (unlimited fallback). Any
|
||||
# positive integer N enables N/day, 5N/week, 20N/month.
|
||||
# Used by: src/shared/utils/apiKeyPolicy.ts — checkRateLimit() fallback.
|
||||
@@ -418,37 +390,19 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# provider dispatch. Heavyweight capacity is reserved before parsing; excess work
|
||||
# receives 503 + Retry-After instead of overlapping until the process OOMs.
|
||||
# Used by: src/shared/middleware/chatBodyAdmission.ts
|
||||
# Actual bodies at or above this size take the heavyweight lease (BYTE path,
|
||||
# including POST /v1/responses) and use the same #10437 healthy-headroom escape
|
||||
# as structure-heavy. Default 262144 (256 KB).
|
||||
# Actual bodies at or above this size require a heavyweight lease. Default 262144 (256 KB).
|
||||
# OMNIROUTE_CHAT_LARGE_BODY_BYTES=262144
|
||||
# Actual-byte hard cap enforced during bounded ingestion. Default 52428800 (50 MB).
|
||||
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
|
||||
# Legacy request-COUNT cap (#503-fanout). Now binds only when explicitly set here —
|
||||
# left unset, heavyweight admission is gated by OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES below
|
||||
# instead (an auto-derived byte budget), fixing coding-agent fan-out (multiple
|
||||
# subagents/CLIs) collapsing to an effective concurrency of ~1 and 503ing.
|
||||
# Two overlapping ~750k-token /v1/responses abort ~12 Gi heaps (#7849) — a
|
||||
# memory-budget warning, not a hard product max of 2. A healthy heap may admit
|
||||
# more via HEALTHY_HEADROOM. Tens of long SSE clients (40-50) is heap +
|
||||
# OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES / #10110. Multiply heaps with N independent
|
||||
# DATA_DIRs (#11024); never replicas>1 on one SQLite.
|
||||
# Maximum heavyweight requests simultaneously admitted in one process. Default 1.
|
||||
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
|
||||
# Override for the auto-derived ingest byte budget (#503-fanout). Default: 25% of the
|
||||
# process's effective memory ceiling (V8 heap limit, or the tighter cgroup/container
|
||||
# limit) divided by an 8x transient-amplification factor, clamped between 8 MiB and
|
||||
# 2 GiB; explicit overrides are clamped to the same safe range. Read
|
||||
# chatAdmission.maxInflightBytes/budgetSource at /api/monitoring/health before overriding.
|
||||
# OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES=134217728
|
||||
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for BYTE and STRUCTURE
|
||||
# heavyweight admission (#10183, #10268, #10437): a concurrent heavyweight request
|
||||
# past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT is only shed with a retryable 503 when the
|
||||
# heap is ALSO under this much pressure — on a healthy heap it is admitted via
|
||||
# healthy-headroom instead. Range (0, 1]. Default 0.75.
|
||||
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for the structural admission gate
|
||||
# (#10183, #10268): a second concurrent heavyweight request past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
|
||||
# is only shed with a retryable 503 when the heap is ALSO under this much pressure — on a
|
||||
# healthy heap it is admitted instead. Range (0, 1]. Default 0.75.
|
||||
# OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO=0.75
|
||||
# Bounded extra capacity for the healthy-heap fast path above OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
|
||||
# (#10437, BYTE + STRUCTURE, including bodies >= OMNIROUTE_CHAT_LARGE_BODY_BYTES):
|
||||
# once this many concurrent leases are active through the healthy-heap bypass,
|
||||
# (#10437): once this many concurrent leases are active through the healthy-heap bypass,
|
||||
# further busy requests fall through to the same bounded-wait/shed path used under real heap
|
||||
# pressure. 0 disables the bypass entirely. Default 1.
|
||||
# OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM=1
|
||||
@@ -640,9 +594,11 @@ CLOUD_URL=
|
||||
# Default: http://localhost:20128
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:20128
|
||||
|
||||
# Highest-priority OmniRoute public origin override, also used by non-dashboard
|
||||
# public-origin validation. Set it when external clients reach OmniRoute through
|
||||
# a stable LAN, tunnel, or public origin that differs from its internal URL.
|
||||
# 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
|
||||
|
||||
@@ -655,6 +611,28 @@ NEXT_PUBLIC_BASE_URL=http://localhost:20128
|
||||
# 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").
|
||||
# OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS=180000
|
||||
|
||||
# Total in-memory byte budget for the chatgpt-web image cache (used to serve
|
||||
# /v1/chatgpt-web/image/<id>), in megabytes. Default 256. Lower this if you
|
||||
# run OmniRoute on a memory-constrained host; raise it if image generation
|
||||
# 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=
|
||||
|
||||
@@ -686,11 +664,21 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
# open-sse/services/usage.ts.
|
||||
#OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/
|
||||
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
|
||||
# Official OpenCode Go usage endpoint, authenticated with the connection API key.
|
||||
# Override only for relays or test fixtures.
|
||||
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/usage
|
||||
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
|
||||
# OpenCode Go has no public quota API — this has no default and stays
|
||||
# unset unless you explicitly opt in to a self-hosted/mirrored endpoint:
|
||||
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=
|
||||
#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace
|
||||
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings
|
||||
|
||||
# OpenCode Go dashboard quota scraping. Prefer configuring these per connection
|
||||
# in Dashboard → Providers → OpenCode Go. Env vars are useful for headless
|
||||
# deployments or shared server defaults. The cookie is sensitive.
|
||||
#OPENCODE_GO_WORKSPACE_ID=wrk_...
|
||||
#OMNIROUTE_OPENCODE_GO_WORKSPACE_ID=wrk_...
|
||||
#OPENCODE_GO_AUTH_COOKIE=auth=...
|
||||
#OMNIROUTE_OPENCODE_GO_AUTH_COOKIE=auth=...
|
||||
|
||||
# OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of
|
||||
# opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers.
|
||||
# When your clients don't already send them, set this to synthesize the CLI headers
|
||||
@@ -825,14 +813,9 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# CLI_CRUSH_BIN=crush
|
||||
# CLI_OMP_BIN=omp
|
||||
# CLI_LETTA_BIN=letta
|
||||
# CLI_PRIME_AGENT_BIN=prime-agent
|
||||
# Windsurf has no default binary — set this to enable binary detection for it.
|
||||
# CLI_WINDSURF_BIN=windsurf
|
||||
# CLI_AUGGIE_BIN=auggie
|
||||
# CLI_5DIVE_BIN=5dive
|
||||
# 5dive keeps root-owned auth profiles under a system state dir (its own STATE_DIR,
|
||||
# default /var/lib/5dive); override here when it lives elsewhere.
|
||||
# CLI_5DIVE_STATE_DIR=/var/lib/5dive
|
||||
# AUGGIE_BIN=auggie
|
||||
|
||||
# ── ZCode (Z.ai GLM coding-plan CLI) local provider ──
|
||||
@@ -876,17 +859,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Legacy alias for OMNIROUTE_API_KEY.
|
||||
# ROUTER_API_KEY=
|
||||
|
||||
# Days of A2A task history to keep before the daily purge deletes a row.
|
||||
# Used by: src/lib/a2a/taskManager.ts (historyRetentionDays). Unset, non-numeric,
|
||||
# or <= 0 falls back to the default.
|
||||
# OMNIROUTE_A2A_HISTORY_RETENTION_DAYS=30
|
||||
|
||||
# Kill-switch for the A2A memory-hits observability feature (Orchestration Canvas
|
||||
# Fase 2). Set to "0" to skip the memory recall lookup entirely; any other value
|
||||
# (including unset) keeps it enabled.
|
||||
# Used by: src/lib/a2a/taskExecution.ts (collectMemoryHits).
|
||||
# OMNIROUTE_A2A_MEMORY_HITS=1
|
||||
|
||||
# Enable the offline/local Issue Agent recorded-triage endpoint.
|
||||
# Used by: src/app/api/issue-agent/runs/route.ts. Default: disabled.
|
||||
# OMNIROUTE_ISSUE_AGENT_ENABLED=false
|
||||
@@ -935,11 +907,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# web_fetch). Default: 60000. Used by: open-sse/mcp-server/fetchTimeout.ts
|
||||
# OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS=60000
|
||||
|
||||
# Maximum number of local-corpus index instances cached in memory.
|
||||
# Used by: src/lib/localCorpus/configured.ts — bounds the LRU cache of
|
||||
# LocalCorpusIndex objects (one per indexed root directory). Default: 5.
|
||||
# OMNIROUTE_CORPUS_CACHE_SIZE=5
|
||||
|
||||
# Model catalog sync interval in hours.
|
||||
# Used by: src/shared/services/modelSyncScheduler.ts — periodic model refresh.
|
||||
# Default: 24
|
||||
@@ -1003,11 +970,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000.
|
||||
#OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000
|
||||
|
||||
# Cron expression for the call-log export job (destinations configured in the
|
||||
# dashboard under Integrations > Log export). Default: hourly, on the hour.
|
||||
# Used by: src/lib/jobs/logExportJob.ts. Timezone: UTC.
|
||||
#OMNIROUTE_LOG_EXPORT_CRON=0 * * * *
|
||||
|
||||
# Emergency budget-exhaustion fallback (set false or 0 to disable the reroute to
|
||||
# nvidia/openai/gpt-oss-120b when a request fails with a 402 budget error).
|
||||
# Used by: open-sse/services/emergencyFallback.ts. Default: enabled.
|
||||
@@ -1052,27 +1014,10 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# to disable the check. Used by: src/lib/db/migrationRunner.ts. Default: 50.
|
||||
#OMNIROUTE_MAX_PENDING_MIGRATIONS=50
|
||||
|
||||
# Working directory for the check:install-upgrade release gate. It builds two ~3 GB
|
||||
# install trees plus a ~275 MB tarball, so it needs roughly 12 GB — more than the
|
||||
# 12 GB RAM-backed tmpfs that /tmp is on the self-hosted runner, where it exhausted
|
||||
# the tmpfs and npm silently truncated the package. Defaults to <repo>/.install-upgrade
|
||||
# on real disk. Used by: scripts/check/check-install-upgrade.mjs. Default: <repo>/.install-upgrade.
|
||||
#OMNIROUTE_INSTALL_UPGRADE_WORKDIR=/var/tmp/omniroute-install-upgrade
|
||||
|
||||
# Trust user-managed RTK project filter rules without strict signature checks.
|
||||
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
|
||||
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
|
||||
|
||||
# Maximum concurrent synchronous compression workers. Excess jobs wait FIFO.
|
||||
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 2.
|
||||
#OMNI_COMPRESSION_WORKERS=2
|
||||
# Per-job worker timeout (ms). A timed-out worker is terminated and the request fails open.
|
||||
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 120000.
|
||||
#OMNI_COMPRESSION_WORKER_TIMEOUT_MS=120000
|
||||
# Terminate idle compression workers after this many milliseconds.
|
||||
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 60000.
|
||||
#OMNI_COMPRESSION_WORKER_IDLE_MS=60000
|
||||
|
||||
# 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.
|
||||
@@ -1175,6 +1120,11 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
|
||||
# Trae OAuth token override. Used by: open-sse/executors/trae.ts.
|
||||
# TRAE_TOKEN=
|
||||
|
||||
# ── The Old LLM (theoldllm) ──
|
||||
# Playwright navigation timeout (ms) for the browser-backed token capture.
|
||||
# Used by: open-sse/executors/theoldllm.ts. Default: 30000 (30s).
|
||||
# THEOLDLLM_NAV_TIMEOUT_MS=30000
|
||||
|
||||
# ── Gemini / Antigravity (Google-based) ──
|
||||
# These providers ship public OAuth client_id/secret values embedded in their
|
||||
# public CLIs. Defaults are baked into the code via
|
||||
@@ -1273,6 +1223,17 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# VISION_BRIDGE_BASE_URL=
|
||||
# VISION_BRIDGE_API_KEY=
|
||||
|
||||
# ── Raycast Pro (local auto-import) ──
|
||||
# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use
|
||||
# only (no OAuth client_id/secret; token is captured via macOS Auto-Import
|
||||
# from the Keychain + local Raycast SQLite DB, or pasted manually). These
|
||||
# vars are optional manual overrides used by open-sse/services/raycast.ts
|
||||
# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs.
|
||||
# RAYCAST_BEARER_TOKEN=
|
||||
# RAYCAST_DEVICE_ID=
|
||||
# RAYCAST_AID=
|
||||
# RAYCAST_SIG_SECRET=
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1302,7 +1263,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.258 (external, cli)"
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)"
|
||||
|
||||
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
|
||||
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
|
||||
@@ -1332,16 +1293,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# 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
|
||||
#
|
||||
# Override the advertised Claude Code client version independently of
|
||||
# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this
|
||||
# value; a UA-only override is not enough (#12417). Used by:
|
||||
# src/shared/constants/claudeCodeClient.ts.
|
||||
# CLAUDE_CODE_CLIENT_VERSION=2.1.259
|
||||
#
|
||||
# Override the advertised GitHub Copilot CLI version independently of
|
||||
# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts.
|
||||
# GITHUB_COPILOT_CLI_VERSION=1.0.82
|
||||
|
||||
# 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
|
||||
@@ -1349,36 +1300,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# set to true/1/yes to enable. Used by: open-sse/executors/codex.ts.
|
||||
# OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS=true
|
||||
|
||||
# Codex app-server WebSocket transport (opt-in). When a WebSocket URL and a
|
||||
# capability token are both provided, Codex requests are routed through a local
|
||||
# `codex app-server` sidecar over JSON-RPC instead of the HTTP Responses API.
|
||||
# Each var is also settable per-connection via providerSpecificData; the env var
|
||||
# is the process-wide fallback. Used by:
|
||||
# open-sse/executors/codex/appServerConfig.ts.
|
||||
#
|
||||
# WebSocket endpoint of the codex app-server (ws:// or wss://). Required to
|
||||
# enable the transport; leaving it unset keeps Codex on its HTTP transports.
|
||||
# OMNIROUTE_CODEX_APPSERVER_WS=ws://127.0.0.1:8081
|
||||
# Inline capability/bearer token presented to the app-server.
|
||||
# OMNIROUTE_CODEX_APPSERVER_WS_TOKEN=deadbeef...
|
||||
# Path to a file holding the capability token (produced by
|
||||
# `codex app-server --ws-token-file <path>`). Used when the inline token above
|
||||
# is not set.
|
||||
# OMNIROUTE_CODEX_APPSERVER_WS_TOKEN_FILE=/run/codex-ws-token
|
||||
# Working directory the app-server turn runs in (defaults to /tmp).
|
||||
# OMNIROUTE_CODEX_APPSERVER_CWD=/tmp
|
||||
# Approval policy passed to the app-server turn (e.g. never, on-request).
|
||||
# OMNIROUTE_CODEX_APPSERVER_APPROVAL=never
|
||||
# Sandbox policy passed to the app-server turn (e.g. read-only,
|
||||
# workspace-write, danger-full-access). When unset, the executor defaults to
|
||||
# "workspace-write" (hardened; used to be "danger-full-access").
|
||||
# OMNIROUTE_CODEX_APPSERVER_SANDBOX=read-only
|
||||
# Auto-approve the app-server's own approval prompts (command/file/permission
|
||||
# execution on the host). Defaults to OFF — prompts are auto-denied. Set to
|
||||
# true/1/yes only when you trust the deployment to run codex-decided host
|
||||
# commands. Per-connection override: providerSpecificData.codexAppServerAutoApprove.
|
||||
# OMNIROUTE_CODEX_APPSERVER_AUTO_APPROVE=false
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1499,15 +1420,28 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# FIRECRAWL_BASE_URL=https://api.firecrawl.dev
|
||||
# FIRECRAWL_TIMEOUT_MS=30000 # Per-request timeout (default: 30000 = 30s)
|
||||
|
||||
# ── Claude TLS transport (Chromium-fingerprinted client) ──
|
||||
# Used by: open-sse/services/claudeTlsClient.ts — native wreq-js request timeout
|
||||
# plus the absolute JS hard-deadline grace when the native request is wedged.
|
||||
# ── 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
|
||||
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
|
||||
# layered on top of it when the native library is wedged.
|
||||
# OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS=60000
|
||||
# OMNIROUTE_CLAUDE_TLS_GRACE_MS=10000
|
||||
|
||||
# ── Perplexity TLS transport (Firefox-fingerprinted client) ──
|
||||
# Used by: open-sse/services/perplexityTlsClient.ts — native wreq-js request
|
||||
# timeout plus the absolute JS hard-deadline grace.
|
||||
# ── Perplexity TLS sidecar (Firefox-fingerprinted client) ──
|
||||
# Used by: open-sse/services/perplexityTlsClient.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_PPLX_TLS_TIMEOUT_MS=30000
|
||||
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
|
||||
|
||||
@@ -1519,16 +1453,18 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# meta-commentary. Set to 1/true/yes/on to restore the old behavior.
|
||||
# OMNIROUTE_PPLX_SEARCH_HINT=0
|
||||
|
||||
# ── Grok web TLS transport (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/grokTlsClient.ts — native wreq-js request timeout
|
||||
# plus the absolute JS hard-deadline grace.
|
||||
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
|
||||
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
|
||||
# top of it when the native library is wedged.
|
||||
# OMNIROUTE_GROK_TLS_TIMEOUT_MS=60000
|
||||
# OMNIROUTE_GROK_TLS_GRACE_MS=10000
|
||||
|
||||
# ── Notion web TLS transport (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/notionTlsClient.ts — native wreq-js request timeout
|
||||
# plus the absolute JS hard-deadline grace. The notion-web executor raises the
|
||||
# native timeout per request to 180000 for long generations.
|
||||
# ── Notion web TLS sidecar (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/notionTlsClient.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. The notion-web executor raises
|
||||
# the wire timeout per-request to 180000 for long generations.
|
||||
# OMNIROUTE_NOTION_TLS_TIMEOUT_MS=30000
|
||||
# OMNIROUTE_NOTION_TLS_GRACE_MS=10000
|
||||
|
||||
@@ -1546,15 +1482,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# request into the browser-backed path.
|
||||
# OMNIROUTE_BROWSER_POOL=on
|
||||
# WEB_COOKIE_USE_BROWSER=0
|
||||
# Obscura (https://github.com/h4ckf0r0day/obscura) is the primary headless
|
||||
# engine: a lightweight CDP server the pool and cloudflare-playground connect
|
||||
# to before falling back to Chromium. Unset OBSCURA_BIN to auto-detect from
|
||||
# PATH; set OBSCURA_CDP_ENDPOINT to reuse an already-running Obscura instead
|
||||
# of spawning one; set OBSCURA_PORT to pin the spawned serve port.
|
||||
# Used by: open-sse/services/obscura.ts
|
||||
# OBSCURA_BIN=
|
||||
# OBSCURA_CDP_ENDPOINT=
|
||||
# OBSCURA_PORT=
|
||||
|
||||
# ── Kimi Web (international kimi.ai Connect-RPC) ──
|
||||
# Used by: open-sse/executors/kimi-web.ts. Override the base/chat URLs only if
|
||||
@@ -1798,7 +1725,6 @@ APP_LOG_TO_FILE=true
|
||||
|
||||
# Custom directory for CLI plugin discovery (omniroute-cmd-* packages).
|
||||
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
|
||||
# CLI-only: the server-side plugin scanner is pointed by OMNIROUTE_PLUGINS_DIR (section 2).
|
||||
# OMNIROUTE_PLUGIN_PATH=
|
||||
|
||||
# ── Prompt cache (system prompt deduplication) ──
|
||||
@@ -1813,13 +1739,6 @@ APP_LOG_TO_FILE=true
|
||||
# SEMANTIC_CACHE_MAX_BYTES=4194304 # Max total cache size in bytes (default: 4 MB)
|
||||
# SEMANTIC_CACHE_TTL_MS=1800000 # Cache entry TTL (default: 30 minutes)
|
||||
|
||||
# ── Local corpus index cache ──
|
||||
# How many local-corpus roots keep a live in-memory index at once. The cache is
|
||||
# LRU: reaching the limit evicts the least-recently-used root's index, which is
|
||||
# then rebuilt on its next query. Clamped to a minimum of 1; a non-numeric value
|
||||
# falls back to the default. Used by: src/lib/localCorpus/configured.ts
|
||||
# OMNIROUTE_CORPUS_CACHE_SIZE=5
|
||||
|
||||
# ── In-memory log buffers ──
|
||||
# Maximum recent stream events kept in memory for the Dashboard live view.
|
||||
# STREAM_HISTORY_MAX=50
|
||||
@@ -1863,7 +1782,7 @@ APP_LOG_TO_FILE=true
|
||||
# short alias prefix and the canonical provider prefix for each model (cc/claude-sonnet-4-6
|
||||
# AND claude/claude-sonnet-4-6) so client configs that hardcoded either form keep working —
|
||||
# which roughly doubles the catalog. "alias" emits one id per model; "canonical" emits only
|
||||
# the full provider-id prefix (providers whose alias is already canonical keep their one id).
|
||||
# the full provider-id prefix (and drops providers whose alias is already canonical).
|
||||
# A client can override per request with GET /v1/models?prefix=alias instead.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
# Used by: src/shared/constants/featureFlagDefinitions.ts, src/app/api/v1/models/catalog.ts
|
||||
@@ -1933,11 +1852,6 @@ APP_LOG_TO_FILE=true
|
||||
# Default: true
|
||||
# MODEL_CATALOG_INCLUDE_NAMES=true
|
||||
|
||||
# Cold-path wait bound for a coalesced GET /v1/models catalog rebuild (#12627).
|
||||
# Used by: src/app/api/v1/models/catalogCache.ts
|
||||
# Default: 8000 (8 seconds). On timeout, a last-good 200 is served when available.
|
||||
# CATALOG_BUILD_TIMEOUT_MS=8000
|
||||
|
||||
# ── NanoBanana (Image Generation) ──
|
||||
# Polling config for async image generation jobs.
|
||||
# Used by: open-sse/handlers/imageGeneration.ts
|
||||
@@ -1957,6 +1871,12 @@ APP_LOG_TO_FILE=true
|
||||
# Base backoff after a transient 408 response (ms); five attempts maximum.
|
||||
# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000
|
||||
|
||||
# ── Microsoft Designer Web (Image Generation) ──
|
||||
# Polling config for the microsoft-designer-web submit-then-poll image job.
|
||||
# Used by: open-sse/handlers/imageGeneration/providers/designerWeb.ts
|
||||
# DESIGNER_WEB_POLL_TIMEOUT_MS=60000 # Max wait for job completion (default: 60s)
|
||||
# DESIGNER_WEB_POLL_INTERVAL_MS=2000 # Poll frequency (default: 2s)
|
||||
|
||||
# ── Adobe Firefly (Image Upscale) ──
|
||||
# Base delay (ms) for the submit-retry exponential backoff when Adobe Firefly's
|
||||
# upscale job submission is rate-limited. Used by:
|
||||
@@ -2014,26 +1934,6 @@ APP_LOG_TO_FILE=true
|
||||
# Custom path to cloudflared binary for tunnel management.
|
||||
# Used by: src/lib/cloudflaredTunnel.ts
|
||||
# CLOUDFLARED_BIN=/usr/local/bin/cloudflared
|
||||
#
|
||||
# Transport protocol for the tunnel. One of: http2 (default), quic, auto.
|
||||
# CLOUDFLARED_PROTOCOL=http2
|
||||
#
|
||||
# ── Named / persistent tunnel (stable hostname) ──
|
||||
# By default OmniRoute runs an ephemeral quick tunnel (random *.trycloudflare.com
|
||||
# URL that changes on every restart). To bind a stable, named hostname instead,
|
||||
# create a locally-managed tunnel with the cloudflared CLI:
|
||||
# cloudflared tunnel login
|
||||
# cloudflared tunnel create <name>
|
||||
# cloudflared tunnel route dns <name> ai.example.com
|
||||
# then write a ~/.cloudflared/config.yml with `tunnel:`, `credentials-file:`, and
|
||||
# `ingress:` rules routing your hostname to http://localhost:<PORT> (default 20128).
|
||||
# Point OmniRoute at that config to switch into named-tunnel mode — it runs
|
||||
# `cloudflared tunnel --config <path> run`.
|
||||
# CLOUDFLARED_CONFIG=/home/you/.cloudflared/config.yml
|
||||
# CLOUDFLARED_HOSTNAME is optional — when unset, OmniRoute reads the public hostname
|
||||
# from the config's first ingress rule. Set it to override what is reported as
|
||||
# publicUrl/apiUrl.
|
||||
# CLOUDFLARED_HOSTNAME=ai.example.com
|
||||
|
||||
# ── Search cache ──
|
||||
# TTL for search API response caching (Perplexity, Brave, etc.).
|
||||
@@ -2072,11 +1972,6 @@ APP_LOG_TO_FILE=true
|
||||
# CLIPROXYAPI_HOST=127.0.0.1
|
||||
# CLIPROXYAPI_PORT=5544
|
||||
# CLIPROXYAPI_CONFIG_DIR=~/.cli-proxy-api
|
||||
# Data-plane key fallback; the cliproxyapi_api_key setting takes precedence.
|
||||
# CLIPROXYAPI_API_KEY=
|
||||
# Management key for an externally managed instance. Embedded instances use
|
||||
# OmniRoute's encrypted service key.
|
||||
# CLIPROXYAPI_MANAGEMENT_KEY=
|
||||
|
||||
# ── Mux embedded service ──
|
||||
# Override the port where the embedded Mux (coder/mux) agent-orchestration
|
||||
@@ -2177,12 +2072,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/services/rateLimitManager.ts
|
||||
# RATE_LIMIT_MAX_WAIT_MS=15000
|
||||
|
||||
# Limiter-managed execution backstop (Bottleneck `expiration`): bounds a job's
|
||||
# post-dispatch execution, never queue wait. Must stay ABOVE upstream
|
||||
# fetch-start timeouts on non-incremental gateways. Default: 600000 (10 min)
|
||||
# Used by: open-sse/services/rateLimitManager.ts
|
||||
# RATE_LIMIT_EXECUTION_MAX_WAIT_MS=600000
|
||||
|
||||
# Rate limit queue admission cap: reject with 429 queue_full once this many requests
|
||||
# are already queued (0 = disabled/unbounded, the default). Used by: open-sse/services/rateLimitManager.ts
|
||||
# RATE_LIMIT_MAX_QUEUE_DEPTH=0
|
||||
@@ -2306,16 +2195,6 @@ APP_LOG_TO_FILE=true
|
||||
# Cursor image-generation wall clock (ms). Default: 210000.
|
||||
# CURSOR_IMG_TIMEOUT_MS=210000
|
||||
|
||||
# UC (uncensored.com) image-generation result-poll cadence + wall clock (ms).
|
||||
# Used by: open-sse/handlers/imageGeneration/providers/ucImage.ts. Defaults: 2000 / 60000.
|
||||
# UC_IMAGE_POLL_INTERVAL_MS=2000
|
||||
# UC_IMAGE_POLL_TIMEOUT_MS=60000
|
||||
|
||||
# UC (uncensored.com) video-generation result-poll cadence + wall clock (ms).
|
||||
# Used by: open-sse/handlers/videoGeneration/providers/ucVideo.ts. Defaults: 3000 / 300000.
|
||||
# UC_VIDEO_POLL_INTERVAL_MS=3000
|
||||
# UC_VIDEO_POLL_TIMEOUT_MS=300000
|
||||
|
||||
# Shared-seat concurrency gate for Cursor image jobs. Default: 2.
|
||||
# CURSOR_IMG_MAX_CONCURRENT=2
|
||||
|
||||
@@ -2447,9 +2326,6 @@ APP_LOG_TO_FILE=true
|
||||
# Bundled Codeium/language-server extension_version, distinct from Desktop.
|
||||
# Must use x.y.z format; invalid/unset values use the bundled default 1.48.2.
|
||||
# DEVIN_DESKTOP_EXTENSION_VERSION=1.48.2
|
||||
# Optional override for the Codeium seat-management API used by Devin CLI quota.
|
||||
# Used by: open-sse/services/usage/devinCli.ts. Default: https://server.codeium.com
|
||||
# DEVIN_SEAT_API_URL=https://server.codeium.com
|
||||
|
||||
# ── Command Code (custom CLI) callback ──
|
||||
# Local port used for OAuth-style callbacks from the Command Code CLI helper.
|
||||
@@ -2505,10 +2381,10 @@ APP_LOG_TO_FILE=true
|
||||
# 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.
|
||||
# Intentional transformations require an exact reviewed entry in
|
||||
# config/release/changelog-reconciliations.json; there is no runtime bypass.
|
||||
# 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
|
||||
|
||||
# ── Remote audio provider nodes ──
|
||||
# Used by: src/app/api/v1/_shared/audioProviderNodes.ts — lets the /v1/audio/*
|
||||
@@ -2519,6 +2395,14 @@ APP_LOG_TO_FILE=true
|
||||
# When enabled, the node authenticates with the API key stored on its connection.
|
||||
# AUDIO_REMOTE_PROVIDER_NODES=false
|
||||
|
||||
# ── 1Proxy egress pool ──
|
||||
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
|
||||
# CrofAI 1Proxy service. Disable, override URL, or tune the import quality.
|
||||
# ONEPROXY_ENABLED=true
|
||||
# ONEPROXY_API_URL=https://1proxy-api.aitradepulse.com
|
||||
# ONEPROXY_MAX_PROXIES=500
|
||||
# ONEPROXY_MIN_QUALITY_THRESHOLD=50
|
||||
|
||||
# ── Free Proxy Pool (auto-sync scheduler) ──
|
||||
# Background refresh of the free-proxy pool. Opt-in, OFF by default (parallels
|
||||
# Hard Rule #20's default-off posture for data-mutating background features).
|
||||
@@ -2589,6 +2473,11 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: src/lib/jobs/backupScheduleJob.ts
|
||||
# OMNIROUTE_BACKUP_SCHEDULE_JOB_INTERVAL_MS=30000
|
||||
|
||||
# ── TLS sidecar override ──
|
||||
# Used by: open-sse/services/chatgptTlsClient.ts tests. Production deployments
|
||||
# should leave this unset; the sidecar is auto-managed.
|
||||
# OMNIROUTE_TLS_PROXY_URL=
|
||||
|
||||
# ── Skills sandbox (experimental) ──
|
||||
# Used by: src/lib/skills/builtins.ts. All values support comma lists where
|
||||
# noted in the source.
|
||||
@@ -2937,14 +2826,6 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# PROMPTQL_TOKEN_REFRESH_URL=https://auth.pro.ql.app/ddn/project/token
|
||||
# PROMPTQL_POLL_TIMEOUT_MS=180000
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Kilo Code usage quotas (src/shared/constants/providers/kilocode.ts)
|
||||
# Personal USD balance and Kilo Pass usage lookup. Optional — the default
|
||||
# points at the public Kilo API; override only for a relay/test fixture.
|
||||
# Authentication uses the connection's existing OAuth access token.
|
||||
# Used by: open-sse/services/usage/kilocode.ts
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# KILO_API_URL=https://api.kilo.ai
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# HyperAgent web provider (Unofficial/Experimental — src/shared/constants/providers/web-cookie.ts)
|
||||
# Reverse-engineered session bridge for hyperagent.com. Optional — defaults
|
||||
@@ -2964,12 +2845,7 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# CHATGPT_WEB_CODEX_CDP_URL=http://chatgpt-web-codex-browser:9223
|
||||
# CHATGPT_WEB_CODEX_TUNNEL_ID=tunnel_0123456789abcdef0123456789abcdef
|
||||
# CHATGPT_WEB_CODEX_RUNTIME_KEY=
|
||||
# CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex v2
|
||||
# CODEX_CHATGPT_WEB_HOME=/var/lib/omniroute/chatgpt-web-codex
|
||||
# CODEX_CHATGPT_WEB_BROWSER_DIAGNOSTICS=0
|
||||
# CODEX_CHATGPT_WEB_LAUNCHER=/absolute/path/to/codex-chatgpt-web
|
||||
# CODEX_CHATGPT_WEB_BUN=/absolute/path/to/bun
|
||||
# CODEX_WEB_GPT_BUN=/absolute/path/to/bun
|
||||
# CHATGPT_WEB_CODEX_CONNECTOR_NAME=OmniRoute Codex
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Browser-login VNC sessions (optional — src/lib/vncSession/manifest.ts)
|
||||
@@ -3098,17 +2974,3 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# without a configured budget are always considered affordable. Requires the
|
||||
# provider_quota_state table (migration 148).
|
||||
# OMNIROUTE_QUOTA_AWARE_ROUTING=0
|
||||
|
||||
# ─── LOCAL CORPUS (opt-in document index) ───
|
||||
# Size of the in-memory LRU index cache for the local document corpus used by
|
||||
# corpus-aware retrieval. Higher values keep more index entries hot.
|
||||
# Used by: src/lib/localCorpus/configured.ts
|
||||
# OMNIROUTE_CORPUS_CACHE_SIZE=5
|
||||
|
||||
# Service-worker cache-busting id for the PWA shell (#11779). NEXT_PUBLIC_SW_BUILD_ID is
|
||||
# derived at build time from OMNIROUTE_SW_BUILD_ID, then SOURCE_VERSION (set by some PaaS
|
||||
# builders), then the git SHA — override only when the build cannot see git. Used by:
|
||||
# next.config.mjs, scripts/build/assembleStandalone.mjs, src/shared/components/PwaRegister.tsx.
|
||||
#OMNIROUTE_SW_BUILD_ID=2026-08-28T12-00-00
|
||||
#SOURCE_VERSION=abcdef0123456789
|
||||
#NEXT_PUBLIC_SW_BUILD_ID=abcdef0123456789
|
||||
|
||||
11
.gitattributes
vendored
11
.gitattributes
vendored
@@ -1,11 +0,0 @@
|
||||
# Shell scripts must always be checked out with LF line endings.
|
||||
#
|
||||
# On Windows, core.autocrlf=true converts text files to CRLF in the working
|
||||
# tree. Scripts that are kernel-exec'd (Docker ENTRYPOINT, bin/*.sh on Linux
|
||||
# hosts) then fail with `exec ...: no such file or directory` because the
|
||||
# shebang becomes "#!/bin/sh\r". eol=lf overrides autocrlf for these files.
|
||||
*.sh text eol=lf
|
||||
|
||||
# This file must stay LF too: git parses it as-is, and a trailing CR would
|
||||
# corrupt every pattern (e.g. "*.sh\r" matches nothing).
|
||||
.gitattributes text eol=lf
|
||||
48
.github/actions/npm-ci-retry/action.yml
vendored
48
.github/actions/npm-ci-retry/action.yml
vendored
@@ -1,45 +1,9 @@
|
||||
name: npm ci with retry
|
||||
description: >-
|
||||
Install dependencies. Restores node_modules from the Actions cache when the exact
|
||||
lockfile / runner / Node version / postinstall inputs match; otherwise runs npm ci
|
||||
with retries for transient registry/network failures and saves the tree for the
|
||||
next run.
|
||||
inputs:
|
||||
cache:
|
||||
description: Set to "false" to skip the node_modules cache and always run npm ci.
|
||||
required: false
|
||||
default: "true"
|
||||
description: Run npm ci with retries for transient registry/network failures.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve Node version for the cache key
|
||||
id: node
|
||||
shell: bash
|
||||
run: echo "version=$(node --version)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# #8084 D3 (plan 3.8.51 task 5): every job used to pay ~80-90 s of `npm ci` even
|
||||
# with setup-node's npm tarball cache warm — 36 jobs per ci.yml run, ~55 min of
|
||||
# runner time per run just installing. A node_modules cache keyed on EVERYTHING
|
||||
# that shapes the tree lets a hit skip the install entirely.
|
||||
#
|
||||
# No restore-keys on purpose (same rule as the ESLint cache, #11600): a partial
|
||||
# tree from another lockfile / Node / postinstall script is exactly the kind of
|
||||
# silent drift a lockfile-pinned CI must never inherit. Exact key or a full npm ci.
|
||||
#
|
||||
# postinstall (scripts/build/postinstall.mjs + helpers) only mutates node_modules
|
||||
# on a plain install — its dist/ branch is gated on dist/ existing, which never
|
||||
# holds at install time in CI — so the cached tree already carries its effects.
|
||||
- name: Restore node_modules
|
||||
id: node-modules
|
||||
if: inputs.cache == 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: node_modules
|
||||
key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.node.outputs.version }}-${{ hashFiles('package-lock.json', '.npmrc', 'scripts/build/postinstall.mjs', 'scripts/build/postinstallSupport.mjs', 'scripts/build/colocateOptionals.mjs', 'scripts/build/wreqJsNative.mjs', 'scripts/build/fixPlaywrightAndroid.mjs', 'scripts/build/native-binary-compat.mjs') }}
|
||||
|
||||
- name: npm ci (with retry)
|
||||
if: steps.node-modules.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
- shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -51,8 +15,7 @@ runs:
|
||||
echo "npm ci attempt $attempt/$max_attempts after transient failure"
|
||||
fi
|
||||
|
||||
# --no-audit: `audit:deps` is its own gate; the inline audit only adds latency.
|
||||
if npm ci --no-audit --no-fund; then
|
||||
if npm ci; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -64,8 +27,3 @@ runs:
|
||||
sleep "$delay_seconds"
|
||||
delay_seconds=$((delay_seconds * 2))
|
||||
done
|
||||
|
||||
- name: node_modules restored from cache
|
||||
if: steps.node-modules.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
run: echo "node_modules restored from cache (key hit) — npm ci skipped"
|
||||
|
||||
16
.github/dependabot.yml
vendored
16
.github/dependabot.yml
vendored
@@ -58,22 +58,6 @@ updates:
|
||||
# on the VPS — so keep auto-bumps frozen (no update-types = ignore every version).
|
||||
# Migrate it intentionally, not via dependabot (#4050).
|
||||
- dependency-name: "@huggingface/transformers"
|
||||
# onnxruntime-node is the OTHER HALF of the @huggingface/transformers pair frozen
|
||||
# above: the hoisted copy must equal the exact version transformers pins, or npm
|
||||
# nests a second ABI-incompatible native copy (contract test
|
||||
# tests/unit/onnxruntime-single-copy.test.ts, pair established in #9962). A solo
|
||||
# bump can never be correct — it only ever moves together with transformers, in
|
||||
# the same deliberate migration PR. Freezing it keeps the production group PRs
|
||||
# (e.g. #12219) from being born red on the pair contract.
|
||||
- dependency-name: "onnxruntime-node"
|
||||
# eslint-plugin-react-hooks is pinned to 7.0.1 by a contract test
|
||||
# (tests/unit/eslint-react-hooks-version-pinned.test.ts) until the 7.1.1 rule set
|
||||
# is adopted deliberately — that adoption needs a full cold lint run and its own
|
||||
# PR (the #12146 react-hooks migration finished on 2026-09-01, so the path is
|
||||
# open; the bump still must not ride a dependabot group, where it reds the
|
||||
# development group PRs, e.g. #12220). Remove this ignore in the adoption PR
|
||||
# together with the pin test.
|
||||
- dependency-name: "eslint-plugin-react-hooks"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
36
.github/workflows/api-route-typecheck.yml
vendored
36
.github/workflows/api-route-typecheck.yml
vendored
@@ -1,36 +0,0 @@
|
||||
name: API Route Typecheck
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- "release/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-typecheck:
|
||||
name: API Route Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- name: Reject new API-route TypeScript diagnostics
|
||||
run: node scripts/check/check-api-typecheck.mjs
|
||||
- name: API typecheck gate unit tests
|
||||
run: node --import tsx/esm --test tests/unit/build/check-api-typecheck.test.ts
|
||||
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@@ -1,16 +1,9 @@
|
||||
name: Build App
|
||||
|
||||
# Manual-only since #11946. The hosted 7 GB runner can no longer build this tree — 19 of
|
||||
# the last 30 runs died with "The runner has received a shutdown signal" (VM out of
|
||||
# memory) ~8 min into `next build`, release/v3.8.51 itself included, even with the 10 GB
|
||||
# swapfile below. Triggered on `push: branches: ["**"]` it painted every branch and every
|
||||
# PR red while producing an artefact nothing downloads. The bundle is validated where a
|
||||
# build actually fits:
|
||||
# - main: ci.yml `Build` (self-hosted omni-build pool) on every merge
|
||||
# - release/**: nightly-release-green.yml (same pool, continuous)
|
||||
# Dispatch this workflow by hand when a hosted build artefact is genuinely needed.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["**"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
132
.github/workflows/ci.yml
vendored
132
.github/workflows/ci.yml
vendored
@@ -93,7 +93,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
@@ -109,11 +108,8 @@ jobs:
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
|
||||
# cache built under a different suppressions file / lint config / lockfile reports
|
||||
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
|
||||
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
|
||||
# partial cache from another configuration.
|
||||
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)
|
||||
@@ -130,8 +126,6 @@ jobs:
|
||||
- run: npm run check:route-validation:t06
|
||||
- run: npm run check:any-budget:t11
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:model-lifecycle
|
||||
- run: npm run check:provider-asset-provenance
|
||||
- run: npm run check:fetch-targets
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
@@ -212,11 +206,8 @@ jobs:
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
|
||||
# cache built under a different suppressions file / lint config / lockfile reports
|
||||
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
|
||||
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
|
||||
# partial cache from another configuration.
|
||||
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
|
||||
@@ -502,11 +493,6 @@ jobs:
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
||||
# Real-translation ratchet: a leaf copied verbatim from en.json passes key
|
||||
# parity above but is still English to the user (es shipped 55% English).
|
||||
# Advisory in PR-0; flipped to blocking once the backlog is retranslated (PR-4).
|
||||
- name: i18n real-translation ratio (advisory)
|
||||
run: node scripts/i18n/check-translation-ratio.mjs --warn
|
||||
# #8463: a rewritten English value used to leave its 39 translations behind
|
||||
# silently (googleOAuthWarning shipped wrong copy in 39 locales for months).
|
||||
# Key parity above cannot see it — a stale translation counts as covered.
|
||||
@@ -620,24 +606,13 @@ jobs:
|
||||
# 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-build) instead of queueing on the 20-concurrent-job hosted pool.
|
||||
# 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-build"]') || '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' }}
|
||||
needs: changes
|
||||
# The .113 pool runs ONE next-build with room to spare and two at the edge: the
|
||||
# box has 31 GB and a single next-build peaks at 14–16 GB RSS. On 2026-08-28
|
||||
# 13:50Z the kernel OOM-killed main's build while a PR build ran beside it
|
||||
# (five Build jobs had been queued by a burst of PRs). Two lanes: main keeps
|
||||
# its own so a release is never queued behind PR traffic; PR builds serialize
|
||||
# among themselves. docker-publish.yml's amd64 leg joins `heavy-build-main`
|
||||
# so a :next image build waits beside this artefact instead of becoming the
|
||||
# third heavy (#11976). GitHub keeps one running + one pending per group.
|
||||
concurrency:
|
||||
group: heavy-build-${{ github.ref == 'refs/heads/main' && 'main' || 'pr' }}
|
||||
cancel-in-progress: false
|
||||
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -671,14 +646,14 @@ jobs:
|
||||
# Keep standalone/node_modules intact: package/electron jobs consume the
|
||||
# Next-traced standalone tree and must not replace it with root node_modules.
|
||||
run: |
|
||||
tar -czf "$RUNNER_TEMP/e2e-build.tar.gz" \
|
||||
tar -czf /tmp/e2e-build.tar.gz \
|
||||
--exclude='.build/next/cache' \
|
||||
.build/next
|
||||
- name: Upload Next.js build for downstream jobs
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: next-build
|
||||
path: ${{ runner.temp }}/e2e-build.tar.gz
|
||||
path: /tmp/e2e-build.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
package-artifact:
|
||||
@@ -701,63 +676,16 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
# build:cli consumes the downloaded .build/next standalone artifact and assembles dist/;
|
||||
# it only rebuilds if the downloaded standalone artifact is missing.
|
||||
- run: npm run build:cli
|
||||
- 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)
|
||||
# `build:cli` monta dist/ mas NAO grava dist/BUILD_SHA — so `build:release` faz
|
||||
# isso, chamando write-build-sha.mjs. O guard de proveniencia do #10427, dentro
|
||||
# de check:pack-artifact, rejeita um artefato sem SHA (e rejeita mesmo com
|
||||
# OMNIROUTE_ALLOW_CANARY_BUILD=1: o que nao da para identificar nao da para
|
||||
# vouchear). Sem este passo o par build+validate deste job e estruturalmente
|
||||
# incompativel e falha 100% das vezes.
|
||||
- name: Stamp dist/BUILD_SHA for the provenance guard (#10427)
|
||||
# O SHA TEM de vir do head da PR, nao de `git rev-parse HEAD`. Este workflow
|
||||
# roda em `pull_request`, entao o checkout e o MERGE COMMIT efemero que o
|
||||
# GitHub cria — um commit que nao existe em branch nenhuma e portanto nunca e
|
||||
# ancestral da release. O guard de proveniencia (#10427) rejeita exatamente
|
||||
# isso, e com razao: um artefato carimbado com o merge commit nao pode ser
|
||||
# rastreado ate codigo que passou pelos gates.
|
||||
env:
|
||||
OMNIROUTE_BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: |
|
||||
export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}"
|
||||
node scripts/build/write-build-sha.mjs
|
||||
# O guard de proveniencia checa ancestralidade contra `origin/main` por padrao.
|
||||
# Esse e o ref certo na PUBLICACAO (npm-publish.yml roda em main), mas em
|
||||
# `pull_request` e estruturalmente impossivel: enquanto a PR esta aberta o head
|
||||
# dela NUNCA e ancestral de main — e o checkout raso nem traz `origin/main` para
|
||||
# o grafo local, entao a sonda responde `false` de qualquer jeito. Resultado: o
|
||||
# gate falhava 100% das vezes em PR. Pre-merge o unico invariante checavel e "o
|
||||
# stamp corresponde a branch sob teste", entao apontamos o ref para o head da PR.
|
||||
# Usamos `refs/pull/<N>/head` e nao `head.ref` porque aquele existe no PROPRIO
|
||||
# origin mesmo quando a PR vem de um fork; `head.ref` so existe no repo do autor.
|
||||
- name: Resolve the provenance ref for the pack gate (#10427)
|
||||
id: provenance-ref
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
git fetch --no-tags --depth=50 origin \
|
||||
"+refs/pull/$PR_NUMBER/head:refs/remotes/origin/pr-head"
|
||||
echo "ref=origin/pr-head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git fetch --no-tags --depth=50 origin \
|
||||
"+refs/heads/$GITHUB_REF_NAME:refs/remotes/origin/$GITHUB_REF_NAME"
|
||||
echo "ref=origin/$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- run: npm run check:pack-artifact
|
||||
env:
|
||||
OMNIROUTE_RELEASE_REF: ${{ steps.provenance-ref.outputs.ref }}
|
||||
# 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).
|
||||
@@ -796,14 +724,10 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
- name: Install Electron dependencies
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
@@ -868,23 +792,9 @@ jobs:
|
||||
# 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.
|
||||
# Heap: os shards rodam sob instrumentacao de cobertura do V8, que retem muito
|
||||
# mais memoria que a suite crua. Com o teto antigo de 4096 MB os shards passaram
|
||||
# a abortar com SIGABRT (exit 134, "Ineffective mark-compacts near heap limit")
|
||||
# ao redor de 4086 MB conforme o catalogo de providers cresceu no ciclo v3.8.50 —
|
||||
# todos os testes passavam e o processo morria no fim, o que le como falha de
|
||||
# teste sem ser. O teto vive em `test:unit:ci:shard` (package.json) e agora
|
||||
# acompanha os 8192 MB ja usados pelas variantes nao-shardadas; os runners
|
||||
# GitHub-hosted tem 16 GB.
|
||||
- name: Unit tests (shard ${{ matrix.shard }}/8) with V8 coverage
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/8
|
||||
# NODE_OPTIONS (nao so o flag em test:unit:ci:shard) porque quem estoura o
|
||||
# heap e o processo `c8` que embrulha a suite — ele agrega ~577 MB de JSON
|
||||
# de cobertura bruta. Subir o teto so no node filho deixa o pai no default
|
||||
# do V8 (~4 GB) e o OOM continua igual, em ~4083 MB. Mesmo padrao ja usado
|
||||
# pelo job de merge de cobertura mais abaixo.
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: |
|
||||
rm -rf coverage-shard coverage-shard-report
|
||||
npx c8 \
|
||||
@@ -990,11 +900,7 @@ jobs:
|
||||
# 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).
|
||||
# 30, not 20 (2026-08-29): the informational Codecov upload below hung for the rest of
|
||||
# the budget on two consecutive main runs (33207760653, 33215115341); the job ended
|
||||
# `cancelled` and dragged the whole run's conclusion to `cancelled` although every
|
||||
# blocking job was green. The upload step now has its own ceiling; this is headroom.
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
needs: test-unit
|
||||
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
|
||||
env:
|
||||
@@ -1073,10 +979,6 @@ jobs:
|
||||
# (if-no-files-found: warn) — Sonar consumes the same file.
|
||||
- name: Upload coverage to Codecov (informational)
|
||||
if: always()
|
||||
# Informational means informational: its own ceiling and continue-on-error, so a
|
||||
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
@@ -1271,14 +1173,10 @@ jobs:
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
|
||||
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
|
||||
# #11896's first cut broke the Electron smoke on exactly that. A relative path
|
||||
# works in bash and pwsh alike; hosted workspaces are ephemeral.
|
||||
path: next-build-artifact
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf next-build-artifact/e2e-build.tar.gz
|
||||
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
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
- uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
- uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
19
.github/workflows/dast-smoke.yml
vendored
19
.github/workflows/dast-smoke.yml
vendored
@@ -1,15 +1,7 @@
|
||||
name: DAST smoke (PR)
|
||||
# PRs into main only since #11946. The job's "Build CLI bundle" step is a backend-only
|
||||
# `next build`; on the hosted 7 GB runner it fits main's tree (~5.5 min) but dies on
|
||||
# release/v3.8.51 (VM shutdown ~7 min in, before the server even starts), and because the
|
||||
# job is continue-on-error the result was a permanently red advisory check on every
|
||||
# release PR — noise, not signal. DAST coverage for release/** lives on the nightly rail
|
||||
# (nightly-schemathesis.yml, nightly-llm-security.yml); dispatch this workflow by hand
|
||||
# to smoke a release branch on demand.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
branches: ["main", "release/**"]
|
||||
# Runner-cost guard (#8084): the CLI-bundle build alone is 6-11min; a docs-only PR
|
||||
# cannot change DAST behavior, so skip the whole workflow for pure docs/markdown
|
||||
# changes. Any code path in the diff still runs the full smoke.
|
||||
@@ -54,7 +46,6 @@ jobs:
|
||||
env:
|
||||
PORT: "20128"
|
||||
INJECTION_GUARD_MODE: block
|
||||
REQUIRE_API_KEY: "false"
|
||||
run: |
|
||||
node dist/server.js > server.log 2>&1 &
|
||||
echo $! > server.pid
|
||||
@@ -73,20 +64,16 @@ jobs:
|
||||
# those 302s as "the API accepted a schema-violating request" and the configured-off
|
||||
# 400 as "rejected a schema-compliant request". Documenting the flow in the spec is
|
||||
# still right (operators need it); fuzzing it is not what this smoke is for.
|
||||
# /api/auth/login has brute-force rate limiting: repeated failed logins return 429,
|
||||
# which Schemathesis flags as rejection of schema-compliant requests.
|
||||
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
|
||||
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
|
||||
--exclude-path-regex '^/api/auth/(oidc/|login)' \
|
||||
--exclude-path-regex '^/api/auth/oidc/' \
|
||||
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
|
||||
--request-timeout 20 --suppress-health-check all --no-color
|
||||
- name: Install promptfoo
|
||||
run: npm install -g promptfoo@0.122.0
|
||||
- name: promptfoo injection-guard (blocking)
|
||||
env:
|
||||
OMNIROUTE_URL: http://localhost:20128
|
||||
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
|
||||
run: promptfoo eval -c promptfooconfig.yaml --no-cache
|
||||
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
|
||||
181
.github/workflows/docker-publish.yml
vendored
181
.github/workflows/docker-publish.yml
vendored
@@ -26,14 +26,6 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# One publish per ref. A merge storm used to fan out 8 concurrent hosted builds,
|
||||
# every one OOM-killing `npm run build` inside BuildKit (#11976). The :next
|
||||
# channel only needs the newest SHA; cancel-in-progress is the same pattern as
|
||||
# quality.yml / nightly-release-green.
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least-privilege default: read-only at the top level; the build and merge jobs that
|
||||
# push to GHCR grant packages: write themselves (Scorecard TokenPermissions).
|
||||
permissions:
|
||||
@@ -76,16 +68,6 @@ jobs:
|
||||
"$EVENT_NAME" "$REF_TYPE" "$REF_NAME" "$INPUT_VERSION" "$DEFAULT_BRANCH")
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Frozen release branches keep receiving coordination commits after the
|
||||
# next cycle becomes the default branch. They must not overwrite :next,
|
||||
# but that expected no-op is not a workflow failure.
|
||||
if [ "$VERSION" = "skip" ]; then
|
||||
echo "promote_latest=false" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping Docker publish from non-default release branch: $REF_NAME"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2) Decide whether to promote :latest. Floating channels are never
|
||||
# eligible, and the helper independently fails closed for non-semver.
|
||||
PROMOTE="false"
|
||||
@@ -126,23 +108,7 @@ jobs:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.skip != 'true'
|
||||
# amd64: the .113 omni-build pool (31 GB / 32 cores, ONE listener since
|
||||
# #12048). Hosted ubuntu-24.04 is ~7 GB and dies ResourceExhausted (#11976).
|
||||
# Falls back to hosted when USE_VPS_RUNNER is off. arm64: no ARM box — stay
|
||||
# on GitHub's ubuntu-24.04-arm.
|
||||
# Webpack on BOTH arches: Turbopack on omniroute-113-6 hit
|
||||
# TurbopackInternalError "there must be a path to a root" after 26 min
|
||||
# (run 33253576569). The same tree's arm64 webpack build on hosted ARM
|
||||
# succeeded (run 33264823398). Dockerfile already documents webpack as the
|
||||
# Docker escape hatch (OMNIROUTE_USE_TURBOPACK=0).
|
||||
runs-on: ${{ matrix.arch == 'amd64' && (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-24.04') || 'ubuntu-24.04-arm' }}
|
||||
# Share the 1-slot omni-build ceiling (#12048) with ci.yml `Build` /
|
||||
# npm-publish. Same group as main's Build so a :next publish waits beside
|
||||
# the artefact instead of sitting next to it. arm64 is hosted — its own
|
||||
# group, cancelled by the workflow-level concurrency.
|
||||
concurrency:
|
||||
group: ${{ matrix.arch == 'amd64' && 'heavy-build-main' || format('docker-publish-arm-{0}', github.ref) }}
|
||||
cancel-in-progress: ${{ matrix.arch != 'amd64' }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -151,8 +117,10 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
env:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
@@ -165,9 +133,6 @@ jobs:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Assert Docker Engine
|
||||
run: docker info
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -191,14 +156,12 @@ jobs:
|
||||
context: .
|
||||
target: runner-base
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
${{ env.GHCR_IMAGE_NAME }}
|
||||
cache-from: type=gha,scope=docker-${{ matrix.arch }}
|
||||
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max,ignore-error=true
|
||||
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
@@ -210,68 +173,12 @@ jobs:
|
||||
context: .
|
||||
target: runner-web
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
${{ env.GHCR_IMAGE_NAME }}
|
||||
cache-from: type=gha,scope=docker-web-${{ matrix.arch }}
|
||||
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max,ignore-error=true
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
|
||||
- name: Build and push BUN base platform image by digest
|
||||
id: build-bun-base
|
||||
# Bun is a best-effort compatibility target, not a supported runtime
|
||||
# (AGENTS.md -> Environment). Its `bun run build` has been OOM-killing on
|
||||
# both arches; letting that sink the whole publish means the SUPPORTED
|
||||
# runner-base / runner-web images never reach the registry either. The
|
||||
# image is still built and pushed whenever it succeeds — only its power to
|
||||
# block the release is removed.
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.bun
|
||||
target: runner-base
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
${{ env.GHCR_IMAGE_NAME }}
|
||||
cache-from: type=gha,scope=docker-bun-base-${{ matrix.arch }}
|
||||
cache-to: type=gha,scope=docker-bun-base-${{ matrix.arch }},mode=max,ignore-error=true
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
|
||||
- name: Build and push BUN web platform image by digest
|
||||
id: build-bun-web
|
||||
# Bun is a best-effort compatibility target, not a supported runtime
|
||||
# (AGENTS.md -> Environment). Its `bun run build` has been OOM-killing on
|
||||
# both arches; letting that sink the whole publish means the SUPPORTED
|
||||
# runner-base / runner-web images never reach the registry either. The
|
||||
# image is still built and pushed whenever it succeeds — only its power to
|
||||
# block the release is removed.
|
||||
continue-on-error: true
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.bun
|
||||
target: runner-web
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
${{ env.GHCR_IMAGE_NAME }}
|
||||
cache-from: type=gha,scope=docker-bun-web-${{ matrix.arch }}
|
||||
cache-to: type=gha,scope=docker-bun-web-${{ matrix.arch }},mode=max,ignore-error=true
|
||||
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
@@ -280,22 +187,11 @@ jobs:
|
||||
env:
|
||||
DIGEST_BASE: ${{ steps.build.outputs.digest }}
|
||||
DIGEST_WEB: ${{ steps.build-web.outputs.digest }}
|
||||
DIGEST_BUN_BASE: ${{ steps.build-bun-base.outputs.digest }}
|
||||
DIGEST_BUN_WEB: ${{ steps.build-bun-web.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/digests/base /tmp/digests/web /tmp/digests/bun-base /tmp/digests/bun-web
|
||||
mkdir -p /tmp/digests/base /tmp/digests/web
|
||||
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
|
||||
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
|
||||
# Empty when the (non-blocking) bun build produced no image. `if` blocks,
|
||||
# not `[ -n ] && touch`: under `set -e` a failing AND-list aborts the step,
|
||||
# which is precisely the case being handled here.
|
||||
if [ -n "$DIGEST_BUN_BASE" ]; then
|
||||
touch "/tmp/digests/bun-base/${DIGEST_BUN_BASE#sha256:}"
|
||||
fi
|
||||
if [ -n "$DIGEST_BUN_WEB" ]; then
|
||||
touch "/tmp/digests/bun-web/${DIGEST_BUN_WEB#sha256:}"
|
||||
fi
|
||||
|
||||
- name: Upload base digests
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -313,30 +209,6 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload bun-base digests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digests-bun-base-${{ matrix.arch }}
|
||||
path: /tmp/digests/bun-base/*
|
||||
# `ignore`, not `error`: the bun build is non-blocking, so an absent
|
||||
# digest is the expected outcome of a failed/skipped bun image — the
|
||||
# manifest step already treats these tags as optional. Leaving `error`
|
||||
# here just relocates the blocker from the manifest to the upload.
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload bun-web digests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digests-bun-web-${{ matrix.arch }}
|
||||
path: /tmp/digests/bun-web/*
|
||||
# `ignore`, not `error`: the bun build is non-blocking, so an absent
|
||||
# digest is the expected outcome of a failed/skipped bun image — the
|
||||
# manifest step already treats these tags as optional. Leaving `error`
|
||||
# here just relocates the blocker from the manifest to the upload.
|
||||
if-no-files-found: ignore
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Publish multi-arch manifests
|
||||
needs:
|
||||
@@ -391,32 +263,12 @@ jobs:
|
||||
path: /tmp/digests/web
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download bun-base digests
|
||||
# Non-blocking: the bun image is best-effort, so its artifact may not
|
||||
# exist at all. The manifest step treats these tags as optional.
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: digests-bun-base-*
|
||||
path: /tmp/digests/bun-base
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download bun-web digests
|
||||
# Non-blocking: the bun image is best-effort, so its artifact may not
|
||||
# exist at all. The manifest step treats these tags as optional.
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: digests-bun-web-*
|
||||
path: /tmp/digests/bun-web
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Docker Hub manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
create_manifest() {
|
||||
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
|
||||
local image="$1" suffix="$2" dir="$3"
|
||||
local tags=(-t "${image}:${VERSION}${suffix}")
|
||||
if [ "$PROMOTE_LATEST" = "true" ]; then
|
||||
tags+=(-t "${image}:latest${suffix}")
|
||||
@@ -426,10 +278,6 @@ jobs:
|
||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||
done < <(find "$dir" -type f | sort)
|
||||
if [ "${#refs[@]}" -eq 0 ]; then
|
||||
if [ -n "$optional" ]; then
|
||||
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "No image digests in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -438,15 +286,13 @@ jobs:
|
||||
|
||||
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
|
||||
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
|
||||
create_manifest "${IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
|
||||
create_manifest "${IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
|
||||
|
||||
- name: Create GHCR manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
create_manifest() {
|
||||
local image="$1" suffix="$2" dir="$3" optional="${4:-}"
|
||||
local image="$1" suffix="$2" dir="$3"
|
||||
local tags=(-t "${image}:${VERSION}${suffix}")
|
||||
if [ "$PROMOTE_LATEST" = "true" ]; then
|
||||
tags+=(-t "${image}:latest${suffix}")
|
||||
@@ -456,10 +302,6 @@ jobs:
|
||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||
done < <(find "$dir" -type f | sort)
|
||||
if [ "${#refs[@]}" -eq 0 ]; then
|
||||
if [ -n "$optional" ]; then
|
||||
echo "::warning::No image digests in $dir — skipping optional tag ${image}:${VERSION}${suffix}" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "No image digests in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -468,8 +310,6 @@ jobs:
|
||||
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-bun" /tmp/digests/bun-base optional
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-web-bun" /tmp/digests/bun-web optional
|
||||
|
||||
- name: Inspect image
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
@@ -528,14 +368,11 @@ jobs:
|
||||
severity: CRITICAL
|
||||
ignore-unfixed: true
|
||||
exit-code: "1"
|
||||
# Explicit: the advisory scan above already points at it, and the blocking
|
||||
# gate must honour the same accepted-risk list (#12084).
|
||||
trivyignores: .trivyignore
|
||||
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4.37.9
|
||||
uses: github/codeql-action/upload-sarif@v4.37.7
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
55
.github/workflows/electron-release.yml
vendored
55
.github/workflows/electron-release.yml
vendored
@@ -4,21 +4,12 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# A dispatch builds the ref it is dispatched ON (`gh workflow run … --ref v3.8.50` rebuilds
|
||||
# that tag; `--ref main` builds the repaired line). The ref is deliberately NOT an input:
|
||||
# CodeQL flags an input-controlled checkout next to the npm cache on the default branch as
|
||||
# cache poisoning (actions/cache-poisoning/poisonable-step), and `github.ref` is trusted.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g., v1.6.8)"
|
||||
required: true
|
||||
type: string
|
||||
publish_npm:
|
||||
description: "Also run the npm publish leg (turn off when re-attaching desktop assets to a release whose npm package already shipped)"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
# Least-privilege default: read-only at the top level; each job grants the writes it
|
||||
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
|
||||
@@ -85,9 +76,6 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
|
||||
# tag push this resolves to the same commit.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
@@ -173,9 +161,6 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
|
||||
# tag push this resolves to the same commit.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
@@ -187,22 +172,6 @@ jobs:
|
||||
env:
|
||||
NPM_CONFIG_LEGACY_PEER_DEPS: true
|
||||
|
||||
# The Linux leg produces x64 + arm64 installers from one x64 runner. npm
|
||||
# deliberately installs only host-compatible optional dependencies, so
|
||||
# hydrateNativeDeps cannot source the arm64 fork unless we fetch the exact
|
||||
# package pinned in package-lock before either build path runs.
|
||||
- name: Install Linux arm64 wreq binding for cross-package
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
npm install --no-save --ignore-scripts --force --legacy-peer-deps \
|
||||
@wreq-js/binding-linux-arm64-gnu@3.2.0
|
||||
git diff --exit-code -- package.json package-lock.json
|
||||
mkdir -p "$RUNNER_TEMP/omniroute-wreq-verify"
|
||||
DATA_DIR="$RUNNER_TEMP/omniroute-wreq-verify" node --import tsx/esm --test \
|
||||
--test-name-pattern='wreq-js 3.2 manifest pins all nine' \
|
||||
tests/unit/wreq-native-manifest.test.ts
|
||||
|
||||
- name: Sanitize Windows home directory
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
@@ -251,9 +220,9 @@ jobs:
|
||||
# targets, and no unlisted files) byte-for-byte.
|
||||
# hydrate: the bundle was built on ubuntu, so install-machine-forked native
|
||||
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
|
||||
# @wreq-js/binding-*, fsevents) carry linux forks. Replace them with the forks this
|
||||
# fsevents) carry linux forks. Replace them with the forks this
|
||||
# leg's own `npm ci` resolved, then assert every bundled native
|
||||
# (better-sqlite3 prebuilds, wreq-js, onnxruntime)
|
||||
# (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime)
|
||||
# can service this leg's platform/arch before packaging starts.
|
||||
run: |
|
||||
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz
|
||||
@@ -378,8 +347,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# Source archives + SBOM come from the tag being released, not the dispatching branch.
|
||||
ref: ${{ needs.validate.outputs.version }}
|
||||
|
||||
# `merge-multiple` is deliberately OFF. It resolves same-name collisions by ARRIVAL
|
||||
# ORDER, and the two macOS jobs each emit their own `latest-mac.yml` listing only their
|
||||
@@ -437,14 +404,7 @@ jobs:
|
||||
tag_name: ${{ needs.validate.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
# NEVER. Phase 3 of the release flow creates the GitHub Release with the curated
|
||||
# notes seconds after pushing the tag, so by the time this step runs (1-2 h of
|
||||
# builds later) the body already exists — and `true` APPENDS GitHub's
|
||||
# auto-generated "What's Changed" block to it (v3.8.48 shipped that way; the
|
||||
# v3.8.50 re-attach dispatch added +1,416 chars to a 121 KB body, run
|
||||
# 33238093090). A curated body sits ~3 KB under the 125,000-char cap, so the
|
||||
# append can also turn this step RED and leave the release with no assets.
|
||||
generate_release_notes: false
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
files: |
|
||||
release-assets/*.dmg
|
||||
@@ -502,20 +462,11 @@ jobs:
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
needs: [validate, release]
|
||||
# A re-dispatch that only re-attaches desktop assets must not publish the npm package again.
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
|
||||
permissions:
|
||||
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
|
||||
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
|
||||
# A reusable workflow's job cannot request more permission than the caller grants,
|
||||
# so a `read` here makes GitHub reject the run at startup (startup_failure).
|
||||
#
|
||||
# `actions: read` for the same reason: the called `publish` job downloads the next-build
|
||||
# artefact and requests it. v3.8.50 (run 33005490476) died at startup with "The nested
|
||||
# job 'publish' is requesting 'actions: read', but is only allowed 'actions: none'" — and
|
||||
# because `release` lives in this same workflow, the tag shipped with ZERO assets. Keep
|
||||
# this block a superset of every job's permissions in npm-publish.yml.
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write # npm provenance (forwarded to the reusable workflow)
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
|
||||
10
.github/workflows/nightly-llm-security.yml
vendored
10
.github/workflows/nightly-llm-security.yml
vendored
@@ -10,10 +10,7 @@ permissions:
|
||||
jobs:
|
||||
promptfoo-guard:
|
||||
name: promptfoo — injection guard (block mode, no secret)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -49,10 +46,7 @@ jobs:
|
||||
|
||||
garak:
|
||||
name: garak probes (skip without provider secret)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
# NOTE: the `secrets` context is NOT available in a job-level `if:` — referencing
|
||||
# it there makes GitHub reject the file on push (startup_failure on every push).
|
||||
# Map the secret into a job-level env and gate each step on a presence check, so
|
||||
|
||||
119
.github/workflows/nightly-release-green.yml
vendored
119
.github/workflows/nightly-release-green.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
# 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-build"]')) || 'ubuntu-latest' }}
|
||||
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
|
||||
@@ -196,26 +196,6 @@ jobs:
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Close tracking issue when the branch is green again
|
||||
if: steps.validate.outputs.exit == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The open/update step above is the UPWARD half of the loop; without this
|
||||
# step a stale "not green" issue outlives the fix and every base-green check
|
||||
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
|
||||
TITLE="🔴 Release branch not green: ${TARGET}"
|
||||
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 close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
|
||||
--comment "✅ \`${TARGET}\` is release-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
|
||||
echo "Closed issue #$EXISTING"
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -237,7 +217,7 @@ jobs:
|
||||
# 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-build"]')) || 'ubuntu-latest' }}
|
||||
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
|
||||
@@ -314,25 +294,6 @@ jobs:
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Close tracking issue when the branch is green again
|
||||
if: steps.validate.outputs.exit == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The open/update step above is the UPWARD half of the loop; without this
|
||||
# step a stale "not green" issue outlives the fix and every base-green check
|
||||
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
|
||||
TITLE="🔴 main branch not green"
|
||||
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 close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
|
||||
--comment "✅ \`main\` is main-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
|
||||
echo "Closed issue #$EXISTING"
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -423,13 +384,6 @@ jobs:
|
||||
# on `improvements`, complexity-ratchets only when `.improved`), and both exit
|
||||
# non-zero while the branch is over baseline — which is exactly when there is
|
||||
# nothing to bank. Their exit code is not the signal; the verifier below is.
|
||||
# Velocity phase (quality-baseline.json `_policy`, relax-baselines.mjs): the caps
|
||||
# were raised on purpose, so banking the measured shrink would silently undo the
|
||||
# 20% headroom every night. Pause the downward ratchet until the phase closes.
|
||||
if node -e 'process.exit(require("./config/quality/quality-baseline.json")._policy?.phase === "velocity" ? 0 : 1)'; then
|
||||
echo "Velocity phase active — ratchet banking paused (see docs/architecture/QUALITY_GATES.md → Velocity phase)."
|
||||
exit 0
|
||||
fi
|
||||
set +e
|
||||
node scripts/check/check-file-size.mjs --update
|
||||
node scripts/check/check-complexity-ratchets.mjs --update
|
||||
@@ -491,72 +445,3 @@ jobs:
|
||||
gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET" --head "$BANK_BRANCH" \
|
||||
--title "chore(quality): bank ratchet shrinks (${TARGET})" --body-file pr-body.md
|
||||
fi
|
||||
|
||||
# ── Baseline headroom (velocity phase, 2026-08-30 → v4.0) ──────────────────────
|
||||
# The ratchets only speak when a baseline is crossed. With every baseline loosened by
|
||||
# 20% (scripts/quality/relax-baselines.mjs) the question is how fast the budget is
|
||||
# being consumed — this job measures each gate the way CI does and posts the headroom
|
||||
# table to one living issue, so a budget that fills in a week is visible before the
|
||||
# first red PR. Advisory: never fails the workflow.
|
||||
baseline-headroom:
|
||||
name: Baseline headroom
|
||||
if: ${{ github.event_name != 'push' }}
|
||||
timeout-minutes: 60
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
|
||||
- name: Measure headroom on ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/quality/baseline-headroom.mjs \
|
||||
--json reports/quality/headroom.json --md reports/quality/headroom.md
|
||||
cat reports/quality/headroom.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload headroom report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: baseline-headroom-${{ github.run_id }}
|
||||
path: reports/quality/headroom.*
|
||||
retention-days: 90
|
||||
|
||||
- name: Post to the living issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="📈 Baseline headroom (velocity phase)"
|
||||
BAD=$(node -e 'const r=require("./reports/quality/headroom.json").rows;console.log(r.filter(x=>x.status==="critical"||x.status==="warn").length)')
|
||||
{
|
||||
echo "Branch: \`${GITHUB_REF_NAME}\` · run: ${RUN_URL}"
|
||||
echo ""
|
||||
cat reports/quality/headroom.md
|
||||
} > headroom-comment.md
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -z "$EXISTING" ]; then
|
||||
EXISTING=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label quality-gate-finding \
|
||||
--body "Living tracker for the velocity-phase baseline budget (docs/architecture/QUALITY_GATES.md → Velocity phase). One comment per nightly run; the newest comment is the current state." \
|
||||
| grep -oE '[0-9]+$')
|
||||
fi
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file headroom-comment.md
|
||||
if [ "$BAD" != "0" ]; then
|
||||
gh issue edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --add-label "headroom-alert" 2>/dev/null || true
|
||||
else
|
||||
gh issue edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --remove-label "headroom-alert" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
5
.github/workflows/nightly-resilience.yml
vendored
5
.github/workflows/nightly-resilience.yml
vendored
@@ -78,10 +78,7 @@ jobs:
|
||||
|
||||
a11y:
|
||||
name: A11y axe (nightly, freeze-and-alert)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
# The Playwright webServer (`start` mode) builds Next via build-next-isolated.mjs and
|
||||
# boots the standalone server itself (waits on /api/monitoring/health, 15min webServer
|
||||
# timeout). Unlike the per-PR test-e2e job, this nightly job has no pre-built artifact,
|
||||
|
||||
5
.github/workflows/nightly-schemathesis.yml
vendored
5
.github/workflows/nightly-schemathesis.yml
vendored
@@ -10,10 +10,7 @@ permissions:
|
||||
jobs:
|
||||
schemathesis:
|
||||
name: Schemathesis — OpenAPI contract fuzz (advisory)
|
||||
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
|
||||
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
|
||||
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
213
.github/workflows/npm-publish.yml
vendored
213
.github/workflows/npm-publish.yml
vendored
@@ -23,12 +23,11 @@ on:
|
||||
- next
|
||||
- historic
|
||||
publish_mode:
|
||||
description: "auto = publish through npm Trusted Publishing (OIDC, no token, no 2FA prompt — the default); staged = npm stage publish (owner approves with 2FA); direct = legacy token publish (emergency fallback only)"
|
||||
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
|
||||
required: false
|
||||
default: "auto"
|
||||
default: "staged"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- staged
|
||||
- direct
|
||||
workflow_call:
|
||||
@@ -63,15 +62,11 @@ jobs:
|
||||
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
|
||||
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
|
||||
# it is kept verbatim so the expression stays greppable against ci.yml.
|
||||
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-build"]') || 'ubuntu-latest' }}
|
||||
outputs:
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
skip: ${{ steps.resolve.outputs.skip }}
|
||||
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' }}
|
||||
permissions:
|
||||
actions: read # find + download the CI run's next-build artifact for this SHA
|
||||
contents: write # gh release upload (attach SBOM to the GitHub Release)
|
||||
id-token: write # npm provenance (GitHub Packages step)
|
||||
id-token: write # npm provenance
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -205,11 +200,8 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
RUN=""
|
||||
# $RUNNER_TEMP, never /tmp: on the .113 pool /tmp is a 12 GB tmpfs (RAM). Parking
|
||||
# this 1.3 GB artefact there took 27–32 min of the 76-min publish job — the
|
||||
# same bytes upload from disk in 2 min. RUNNER_TEMP is per-runner and on disk.
|
||||
for candidate in $CANDIDATES; do
|
||||
if gh run download "$candidate" --repo "$REPO" --name next-build --dir "$RUNNER_TEMP/next-build" 2>/dev/null; then
|
||||
if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/next-build 2>/dev/null; then
|
||||
RUN="$candidate"
|
||||
break
|
||||
fi
|
||||
@@ -219,8 +211,8 @@ jobs:
|
||||
echo "::notice::none of the candidate runs still carries next-build (1-day retention) — falling back to a full build"
|
||||
exit 0
|
||||
fi
|
||||
tar -xzf "$RUNNER_TEMP/next-build/e2e-build.tar.gz" -C .
|
||||
rm -rf "$RUNNER_TEMP/next-build"
|
||||
tar -xzf /tmp/next-build/e2e-build.tar.gz -C .
|
||||
rm -rf /tmp/next-build
|
||||
if [ -f .build/next/standalone/server.js ]; then
|
||||
echo "✅ standalone tree restored from CI run $RUN — build:cli will skip next build"
|
||||
else
|
||||
@@ -234,28 +226,6 @@ jobs:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
run: npm run build:cli
|
||||
|
||||
# `build:cli` assembles dist/ but does NOT write dist/BUILD_SHA — only
|
||||
# `build:release` does, by calling write-build-sha.mjs. The #10427 provenance
|
||||
# guard inside check:pack-artifact rejects an artifact with no SHA (and rejects
|
||||
# it even under OMNIROUTE_ALLOW_CANARY_BUILD=1: what cannot be identified cannot
|
||||
# be vouched for). Without this step the build+validate pair in this job is
|
||||
# structurally incompatible and fails 100% of the time — the same gap that was
|
||||
# fixed in ci.yml's Package Artifact job.
|
||||
- name: Stamp dist/BUILD_SHA for the provenance guard (#10427)
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
OMNIROUTE_BUILD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
export OMNIROUTE_BUILD_SHA="${OMNIROUTE_BUILD_SHA:0:7}"
|
||||
node scripts/build/write-build-sha.mjs
|
||||
|
||||
# The guard checks ancestry against origin/main by default, which is correct
|
||||
# here (a release tag is cut from main), but the ref has to exist locally for
|
||||
# `git merge-base` to resolve it.
|
||||
- name: Fetch main for the provenance probe
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: git fetch --no-tags --depth=50 origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
- name: Validate npm package artifact
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: npm run check:pack-artifact
|
||||
@@ -273,20 +243,11 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach SBOM to GitHub Release
|
||||
# Not only on the `release` event: the v3.8.50 package shipped through a
|
||||
# workflow_dispatch (staged publish, 11 attempts) and this step was skipped, so the
|
||||
# GitHub Release carried no SBOM until it was attached by hand from the run's
|
||||
# `sbom-npm` artifact. Attach whenever a release for the published tag exists.
|
||||
if: steps.resolve.outputs.skip != 'true' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
|
||||
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'release'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.event_name == 'release' && github.ref_name || format('v{0}', inputs.version) }}
|
||||
run: |
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::notice::no GitHub Release for $TAG yet — SBOM stays on the sbom-npm workflow artifact"
|
||||
exit 0
|
||||
fi
|
||||
gh release upload "$TAG" sbom-npm.cdx.json --repo "$GITHUB_REPOSITORY" --clobber
|
||||
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
|
||||
@@ -304,12 +265,7 @@ jobs:
|
||||
# a staged package that is never approved simply expires, with no `npm deprecate` needed.
|
||||
- name: Prove clean-install AND upgrade-over-previous both boot
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
# 60, not 30. This gate was added in #8953 and the 2026-08-27 v3.8.50 publish
|
||||
# was the FIRST run to ever reach it — every earlier attempt died upstream, so
|
||||
# its budget had never been measured against a real run. It then blew the limit
|
||||
# on its debut: `npm pack` alone took 24m37s, leaving 5 minutes for two installs
|
||||
# and two boots. 30 was a guess; 60 is sized to the one measurement we have.
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
run: npm run check:install-upgrade
|
||||
|
||||
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
|
||||
@@ -331,133 +287,17 @@ jobs:
|
||||
fi
|
||||
npm --version
|
||||
|
||||
# The registry upload itself moved to the `stage-npm` job below: npm REFUSES
|
||||
# `--provenance` from a self-hosted runner (422 "Unsupported GitHub Actions
|
||||
# runner environment"), and the heavy verification above cannot move to a
|
||||
# hosted one (16 GB is not enough for build:cli's next-build fallback — see
|
||||
# this job's runs-on comment). So this job proves the bytes and hands them
|
||||
# over; a tiny hosted job does the upload.
|
||||
- name: Pack the verified tarball for the upload job
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# --ignore-scripts: prepublishOnly would re-run build:cli-api && build:cli,
|
||||
# rebuilding bytes this job has already built, validated and boot-smoked.
|
||||
npm pack --ignore-scripts
|
||||
TARBALL="omniroute-${VERSION}.tgz"
|
||||
test -f "$TARBALL" || { echo "expected $TARBALL to exist after npm pack" >&2; ls -la ./*.tgz || true; exit 1; }
|
||||
echo "packed $TARBALL ($(du -h "$TARBALL" | cut -f1))"
|
||||
|
||||
- name: Hand the tarball to the hosted publish job
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: npm-tarball
|
||||
path: omniroute-${{ steps.resolve.outputs.version }}.tgz
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish to GitHub Packages
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
- 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 }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Configuring for GitHub Packages..."
|
||||
echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" > .npmrc
|
||||
npm pkg set name="@diegosouzapw/omniroute"
|
||||
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" \
|
||||
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
|
||||
echo "✅ Action finished for GitHub Packages"
|
||||
|
||||
# npm REFUSES `--provenance` from a self-hosted runner:
|
||||
# 422 Unprocessable Entity - Error verifying sigstore provenance bundle:
|
||||
# Unsupported GitHub Actions runner environment: "self-hosted".
|
||||
# Only "github-hosted" runners are supported when publishing with provenance.
|
||||
# v3.8.49 published fine because it predates USE_VPS_RUNNER being turned on
|
||||
# (2026-08-02); v3.8.50 was the first release after it, so this had been latent
|
||||
# for four weeks. Dropping --provenance was not an option: 3.8.49 carries a
|
||||
# SLSA attestation and 3.8.50 must not regress that.
|
||||
# The `publish` job cannot simply move to a hosted runner either — 16 GB is not
|
||||
# enough for build:cli's next-build fallback. So it keeps proving the bytes and
|
||||
# this job, which needs no memory at all, performs the upload.
|
||||
stage-npm:
|
||||
needs: publish
|
||||
if: needs.publish.outputs.skip != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # npm provenance — the whole reason this job is separate
|
||||
steps:
|
||||
- name: Download the tarball the publish job proved
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: npm-tarball
|
||||
path: .
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Ensure npm supports staged publishing
|
||||
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 a 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
|
||||
|
||||
# Trusted Publishing (OIDC): npm mints a short-lived credential for THIS run from
|
||||
# GitHub's id-token — no NPM_TOKEN secret, no 2FA prompt, provenance included, and
|
||||
# it is the bypass npm sanctions now that tokens which skip 2FA are being retired
|
||||
# (gh.io/npm-gat-bypass2fa-deprecation). Requires the package's Trusted Publisher to
|
||||
# be configured on npmjs.com (owner: diegosouzapw/OmniRoute, workflow
|
||||
# npm-publish.yml) and a github-hosted runner — which is why this job exists.
|
||||
# Without that configuration `npm publish` fails with ENEEDAUTH: re-dispatch with
|
||||
# publish_mode=staged or direct. Automatic publishing was the flow up to v3.8.48;
|
||||
# v3.8.49 moved to staged (WS1.3) to keep a leaked token from publishing alone —
|
||||
# OIDC gives the same guarantee without the manual approve.
|
||||
- name: Publish to npm (Trusted Publishing / OIDC — automatic)
|
||||
if: github.event_name != 'workflow_dispatch' || inputs.publish_mode == 'auto'
|
||||
env:
|
||||
VERSION: ${{ needs.publish.outputs.version }}
|
||||
TAG: ${{ needs.publish.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL="omniroute-${VERSION}.tgz"
|
||||
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||||
# Deliberately NO NODE_AUTH_TOKEN in this step: npm >= 11.5 detects the GitHub
|
||||
# OIDC token itself. Always pass --tag explicitly (defense in depth: an older
|
||||
# VERSION can never claim `@latest`).
|
||||
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) via Trusted Publishing"
|
||||
|
||||
- name: Publish to npm (staged — owner approves with 2FA)
|
||||
# Only on an explicit request now: Trusted Publishing below is the default.
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'staged'
|
||||
env:
|
||||
VERSION: ${{ needs.publish.outputs.version }}
|
||||
TAG: ${{ needs.publish.outputs.tag }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL="omniroute-${VERSION}.tgz"
|
||||
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||||
# Always pass --tag explicitly. Defense in depth: even if VERSION is
|
||||
# accidentally an older release, the historic tag will NOT claim `@latest`.
|
||||
# --ignore-scripts: publishing a built tarball must never re-run
|
||||
# prepublishOnly (build:cli-api && build:cli) on this small runner.
|
||||
npm stage publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||||
npm stage publish --provenance --access public --tag "$TAG"
|
||||
{
|
||||
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
|
||||
echo ""
|
||||
@@ -473,18 +313,31 @@ jobs:
|
||||
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
|
||||
|
||||
- name: Publish to npm (DIRECT — emergency fallback)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
|
||||
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
|
||||
env:
|
||||
VERSION: ${{ needs.publish.outputs.version }}
|
||||
TAG: ${{ needs.publish.outputs.tag }}
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL="omniroute-${VERSION}.tgz"
|
||||
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
|
||||
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
|
||||
npm publish --provenance --access public --tag "$TAG"
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
|
||||
|
||||
- name: Publish to GitHub Packages
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Configuring for GitHub Packages..."
|
||||
echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" > .npmrc
|
||||
npm pkg set name="@diegosouzapw/omniroute"
|
||||
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" \
|
||||
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
|
||||
echo "✅ Action finished for GitHub Packages"
|
||||
|
||||
publish-opencode-plugin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
4
.github/workflows/opencode-plugin-ci.yml
vendored
4
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -2,11 +2,11 @@ name: opencode-plugin CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, "release/**"]
|
||||
branches: [main, release/v3.8.2]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
pull_request:
|
||||
branches: [main, "release/**"]
|
||||
branches: [main, release/v3.8.2]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
52
.github/workflows/quality.yml
vendored
52
.github/workflows/quality.yml
vendored
@@ -61,25 +61,13 @@ jobs:
|
||||
name: Build (advisory)
|
||||
needs: changes
|
||||
# FORK PRs ONLY. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]`
|
||||
# (#11946, 2026-08-29: build.yml is now workflow_dispatch-only — the hosted runner cannot
|
||||
# build this tree in any profile, 8/8 recent fork PRs included — so own-origin PRs rely on
|
||||
# ci.yml `Build` after merge to main and on nightly-release-green for release/**.)
|
||||
# and runs `build:release` — a superset of this job — so for an own-origin branch this job
|
||||
# was building the same tree twice. A fork contributor pushes to THEIR repo, so that push
|
||||
# never fires here, and this is the only pre-merge build signal they get. Measured
|
||||
# 2026-08-14: 72 of the last 100 PRs into release/** came from forks, so the fork case is
|
||||
# the majority of the traffic, not the exception — this job earns its place, it just should
|
||||
# not duplicate build.yml for the own-origin 28%.
|
||||
# Disabled 2026-08-29 (#11976 follow-up). `continue-on-error: true` still
|
||||
# reports a GitHub check FAILURE, so every fork PR into release/** was born
|
||||
# with a red "Build (advisory)" even when every required gate was green
|
||||
# (sweep-reds, 41 PRs). Hosted ubuntu-latest cannot finish `npm run build`
|
||||
# on this tree — VM shutdown ~6 min in, same class as build.yml going
|
||||
# workflow_dispatch-only in #11962. Pre-merge build signal for release/**
|
||||
# is nightly-release-green (omni-build); for main it is ci.yml `Build`.
|
||||
# Restore this job when a runner that actually fits the tree is wired here.
|
||||
# Bare `false` (not `${{ false }}`) — zizmor obfuscation flags the expression form.
|
||||
if: false
|
||||
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true' && github.event.pull_request.head.repo.full_name != github.repository) }}
|
||||
# PINNED to hosted — this was the last job in THIS workflow still on the USE_VPS_RUNNER
|
||||
# switch (ci.yml's Build, nightly-release-green and npm-publish keep it, so the variable
|
||||
# stays meaningful), and with USE_VPS_RUNNER=true it produced NO signal at all here.
|
||||
@@ -201,11 +189,8 @@ jobs:
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
|
||||
# cache built under a different suppressions file / lint config / lockfile reports
|
||||
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
|
||||
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
|
||||
# partial cache from another configuration.
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
# Security scanners — same hardened install as ci.yml quality-extended
|
||||
# (gh release download = authenticated, 5000 req/hr; curl to api.github.com
|
||||
# is rate-limited to 60/hr and silently no-ops when throttled). The blocking
|
||||
@@ -284,11 +269,11 @@ jobs:
|
||||
run: |
|
||||
set -uo pipefail
|
||||
gates=(
|
||||
provider-consistency provider-asset-provenance fetch-targets deps file-size error-helper
|
||||
provider-consistency fetch-targets deps file-size error-helper
|
||||
migration-numbering public-creds db-rules known-symbols
|
||||
route-guard-membership test-discovery test-runner-api
|
||||
mutation-test-coverage any-budget:t11 build-scope pack-policy
|
||||
complexity-ratchets model-lifecycle
|
||||
complexity-ratchets
|
||||
cycles lockfile duplication dead-code type-coverage compression-budget
|
||||
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
|
||||
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
|
||||
@@ -303,12 +288,7 @@ jobs:
|
||||
# #8522: file-size is base-relative on PR events (compare against
|
||||
# max(frozen, base)) so inherited drift doesn't red an innocent PR;
|
||||
# workflow_dispatch (no PR base) falls back to absolute comparison.
|
||||
# New-code mode (Clean-as-You-Code, 2026-08-30): complexity-ratchets and
|
||||
# dead-code compare the PR's files against the merge-base and block only on
|
||||
# what the PR added; the global totals are advisory on PRs and re-frozen at
|
||||
# release. See scripts/check/newCodeMode.mjs.
|
||||
case "$g" in file-size|complexity-ratchets|dead-code) NEW_CODE=1 ;; *) NEW_CODE= ;; esac
|
||||
if [ -n "$NEW_CODE" ] && [ -n "${PR_BASE_SHA:-}" ]; then
|
||||
if [ "$g" = "file-size" ] && [ -n "${PR_BASE_SHA:-}" ]; then
|
||||
npm run "check:$g" -- --base-ref "$PR_BASE_SHA" || failed+=("$g")
|
||||
else
|
||||
npm run "check:$g" || failed+=("$g")
|
||||
@@ -480,12 +460,6 @@ jobs:
|
||||
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
|
||||
# self-hosted is strictly worse here and there is nothing to configure.
|
||||
runs-on: ubuntu-latest
|
||||
# A shard finishes in ~10 min. Without a ceiling a hung test process holds the PR for
|
||||
# GitHub's 6 h default: on 2026-08-28 shard 1/4 sat 64 min without a line of output
|
||||
# (twice, same spot — a timing race, gone on the third run) while the other three
|
||||
# shards were long green. 30 min = 3x the normal wall-clock; a shard that needs more
|
||||
# is a hang, not a slow run, and a fast red with a re-run beats a silent 6 h hold.
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -526,10 +500,7 @@ jobs:
|
||||
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: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
|
||||
# 2026-08-30: a cold full lint with the eslint-plugin-react-hooks 7 compiler rules is
|
||||
# killed on the 7 GB hosted runner without a message (status null → exit 1, the
|
||||
# JSON never written); the box lints it in ~12 min with the heap below.
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||
# G0 (trilho .50): security-events:read lets the CodeQL ratchet below read open
|
||||
# code-scanning alerts via `gh api .../code-scanning/alerts` (same as ci.yml's
|
||||
@@ -553,16 +524,11 @@ jobs:
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
|
||||
# cache built under a different suppressions file / lint config / lockfile reports
|
||||
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
|
||||
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
|
||||
# partial cache from another configuration.
|
||||
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
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
# ── G0 (trilho .50): motor de ratchet também no trilho B ─────────────────────
|
||||
# This job just wrote .artifacts/eslint-results.json — collect-metrics prefers
|
||||
# that file, so the ratchet engine lands here at ZERO extra ESLint cost (one
|
||||
|
||||
5
.github/workflows/scorecard.yml
vendored
5
.github/workflows/scorecard.yml
vendored
@@ -4,15 +4,12 @@ on:
|
||||
schedule:
|
||||
- cron: "27 7 * * 1"
|
||||
push:
|
||||
# Scorecard only accepts the DEFAULT branch — here the active release/vX.Y.Z,
|
||||
# not `main`. The job below guards on it so a push to any other branch skips.
|
||||
branches: ["main", "release/**"]
|
||||
branches: ["main"]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
if: ${{ github.event_name != 'push' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -14,7 +14,6 @@ _tasks/
|
||||
.agents/**
|
||||
.claude/**
|
||||
.gemini/**
|
||||
.code-forge/**
|
||||
.config/**
|
||||
.data/**
|
||||
.logs/**
|
||||
@@ -293,6 +292,3 @@ docker-compose.yml.bak
|
||||
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
|
||||
/.sandbox/
|
||||
.aider*
|
||||
|
||||
# check:install-upgrade work trees (~12 GB, disposable)
|
||||
/.install-upgrade/
|
||||
|
||||
@@ -92,9 +92,5 @@
|
||||
# - x-api-key PUBLICO do Firefly web (documentado em open-sse/utils/publicCreds.ts:207);
|
||||
# as duas ocorrencias sinalizadas estao em COMENTARIOS JSDoc, o runtime le de resolvePublicCred().
|
||||
'''omniroute-kimi-sponsor-banner-dismissed-v\d+''',
|
||||
# CheaperInference sponsor banner localStorage key (upstream #11196 /
|
||||
# eb5797370). Same UI-identifier pattern as the kimi banner above, not a
|
||||
# credential; the generic-api-key rule flags the long hyphenated string.
|
||||
'''omniroute-cheaperinference-sponsor-banner-dismissed-v\d+''',
|
||||
'''SunbreakWebUI1''',
|
||||
]
|
||||
|
||||
5867
.i18n-state.json
5867
.i18n-state.json
File diff suppressed because it is too large
Load Diff
40
.mailmap
40
.mailmap
@@ -1,40 +0,0 @@
|
||||
# .mailmap — canonical author identities for git log/shortlog/blame.
|
||||
#
|
||||
# Why this file exists: between 2026-08-13 and 2026-08-26 this checkout carried a
|
||||
# `git config --local` whose user.name was one contributor's ("Xiangzhe" / @xz-dev)
|
||||
# and whose user.email was ANOTHER contributor's (@backryun). Every commit produced
|
||||
# on this machine in that window was therefore signed with @backryun's address —
|
||||
# 237 commits, all in the -0300 timezone, while @backryun's own work commits from
|
||||
# +0900 and continued normally throughout. The local override was removed on
|
||||
# 2026-08-26; this file repairs the RECORD without rewriting published history
|
||||
# (those commits live on release/v3.8.50 and release/v3.8.51, which other sessions
|
||||
# and open PRs build on — a rewrite would force-push both and orphan the v3.8.50 tag).
|
||||
#
|
||||
# Format: Canonical Name <canonical@email> Commit Name <commit@email>
|
||||
|
||||
# --- Maintainer: several addresses used over the project's life ---
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouza.pw@gmail.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouza.pw@outlook.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouzapw@users.noreply.github.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Diego Souza <8016841+diegosouzapw@users.noreply.github.com>
|
||||
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diego.souza.pw@gmail.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <souzamiriamrodrigues790@gmail.com>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diego.souza@cdwasolutions.com.br>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> <diegosouzapw@devbox.local>
|
||||
|
||||
# --- The misattribution window: name Xiangzhe + @backryun's email, from -0300.
|
||||
# These are maintainer/session commits, NOT @backryun's contributions.
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Xiangzhe <bakryun0718@proton.me>
|
||||
diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Xiangzhe <diegosouza.pw@gmail.com>
|
||||
|
||||
# --- Xiangzhe (@xz-dev) — a distinct contributor; keep their own work intact ---
|
||||
Xiangzhe <32761048+xz-dev@users.noreply.github.com> <xz-dev@users.noreply.github.com>
|
||||
Xiangzhe <32761048+xz-dev@users.noreply.github.com> <xiangzhedev@gmail.com>
|
||||
|
||||
# --- @backryun's own alternate addresses (their real work, kept intact) ---
|
||||
backryun <24198422+backryun@users.noreply.github.com> <bakryun0718@proton.me>
|
||||
backryun <24198422+backryun@users.noreply.github.com> <backryun@daonlab.local>
|
||||
backryun <24198422+backryun@users.noreply.github.com> <busan011@ormbiz.co.kr>
|
||||
backryun <24198422+backryun@users.noreply.github.com> <backryun@users.noreply.github.com>
|
||||
@@ -18,3 +18,5 @@
|
||||
#
|
||||
# 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.)
|
||||
|
||||
@@ -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/telemetry.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/feature-defaults.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 tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts tests/models-fetcher.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/feature-defaults.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 tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -75,7 +75,6 @@ import {
|
||||
AUTO_VARIANT_DESCRIPTIONS,
|
||||
type FreeModelFreeType,
|
||||
} from "./naming.js";
|
||||
import { applyOmniRouteInferenceTelemetry } from "./telemetry.js";
|
||||
|
||||
/**
|
||||
* Minimal leveled logger sink accepted by the default fetchers and the static
|
||||
@@ -1162,8 +1161,6 @@ export interface OmniRouteRawModelEntry {
|
||||
attachment?: boolean;
|
||||
structured_output?: boolean;
|
||||
temperature?: boolean;
|
||||
/** Runtime-learned or synced reasoning tiers (server-gated, blind-mapped). */
|
||||
effort_tiers?: string[];
|
||||
};
|
||||
release_date?: string;
|
||||
last_updated?: string;
|
||||
@@ -1200,7 +1197,7 @@ export type OmniRouteModelsFetcher = (
|
||||
export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 30_000
|
||||
timeoutMs = 10_000
|
||||
) => {
|
||||
if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models");
|
||||
if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models");
|
||||
@@ -1222,12 +1219,9 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = new Error(
|
||||
throw new Error(
|
||||
`@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`
|
||||
) as Error & { statusCode: number; status: number };
|
||||
err.statusCode = res.status;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
);
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
const rawList: unknown[] = Array.isArray(body)
|
||||
@@ -1308,18 +1302,6 @@ export function mapRawModelToModelV2(
|
||||
ctx: { providerId: string; baseURL: string; apiFormat?: { anthropicPrefixes?: string[] } }
|
||||
): ModelV2 {
|
||||
const caps = raw.capabilities ?? {};
|
||||
// effort_tiers loop: server-declared tiers become ModelV2 variants so the
|
||||
// UI offers exactly the tiers OmniRoute vouches for (instead of opencode's
|
||||
// invented [low, medium, high] fallback). Blind: filtering/exclusion rules
|
||||
// live server-side. Absent/empty/malformed => key omitted ENTIRELY (an
|
||||
// empty variants object would suppress opencode's fallback for this model).
|
||||
const declaredTiers = Array.isArray(caps.effort_tiers)
|
||||
? caps.effort_tiers.filter((t): t is string => typeof t === "string" && t.length > 0)
|
||||
: [];
|
||||
const variants =
|
||||
declaredTiers.length > 0
|
||||
? Object.fromEntries(declaredTiers.map((tier) => [tier, { reasoningEffort: tier }]))
|
||||
: undefined;
|
||||
const inMods = new Set(raw.input_modalities ?? ["text"]);
|
||||
const outMods = new Set(raw.output_modalities ?? ["text"]);
|
||||
|
||||
@@ -1333,7 +1315,10 @@ export function mapRawModelToModelV2(
|
||||
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under
|
||||
// the plugin provider (#10345). Other bare ids still prefix with
|
||||
// `providerId` so credentials resolve as `(omniroute, model)`.
|
||||
id: raw.id.includes("/") || raw.owned_by === "combo" ? raw.id : `${ctx.providerId}/${raw.id}`,
|
||||
id:
|
||||
raw.id.includes("/") || raw.owned_by === "combo"
|
||||
? raw.id
|
||||
: `${ctx.providerId}/${raw.id}`,
|
||||
/**
|
||||
* Display name. Falls back to raw.id when no enrichment is available;
|
||||
* the caller (`createOmniRouteProviderHook`) overlays
|
||||
@@ -1372,7 +1357,6 @@ export function mapRawModelToModelV2(
|
||||
...(typeof raw.max_input_tokens === "number" ? { input: raw.max_input_tokens } : {}),
|
||||
output: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0,
|
||||
},
|
||||
...(variants ? { variants } : {}),
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
@@ -3481,7 +3465,7 @@ export function createOmniRouteProviderHook(
|
||||
|
||||
// ── Combo LCD across nested combo-refs (T-NN) ───────────────────────
|
||||
// Combos can nest other combos via `kind: "combo-ref"` members
|
||||
// (e.g. MASTER-LIGHT contains LEGACY, KIRO, Opecode Zen FREE). The
|
||||
// (e.g. MASTER-LIGHT contains OldLLM, KIRO, Opecode Zen FREE). The
|
||||
// nested combo's own `limit.context` is computed below in this same
|
||||
// loop, so we need a fixpoint iteration: if a combo-ref points at a
|
||||
// combo not yet processed, defer this combo and try again after the
|
||||
@@ -3770,8 +3754,6 @@ export function createOmniRouteFetchInterceptor(config: {
|
||||
baseOrigin = baseUrl.origin;
|
||||
const basePath = ensureV1Suffix(baseUrl.pathname);
|
||||
inferencePaths.add(`${basePath}/chat/completions`);
|
||||
inferencePaths.add(`${basePath}/responses`);
|
||||
inferencePaths.add(`${basePath}/messages`);
|
||||
inferencePaths.add(`${basePath}/models`);
|
||||
} catch {
|
||||
// Credential-attached base URLs are not schema-validated. A malformed
|
||||
@@ -3815,7 +3797,7 @@ export function createOmniRouteFetchInterceptor(config: {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return applyOmniRouteInferenceTelemetry(await fetch(input, { ...init, headers }));
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4501,7 +4483,7 @@ export function buildStaticProviderEntry(
|
||||
// ── Combo LCD across nested combo-refs (T-NN mirror) ─────────────────
|
||||
// Mirror of the dynamic-catalog fixpoint iteration: combos can nest
|
||||
// other combos via `kind: "combo-ref"` members (e.g. MASTER-LIGHT
|
||||
// contains LEGACY, KIRO, Opecode Zen FREE). The nested combo's own
|
||||
// contains OldLLM, KIRO, Opecode Zen FREE). The nested combo's own
|
||||
// capabilities and limits are computed in this same loop, so we need
|
||||
// a fixpoint pass: if a combo-ref points at a combo not yet processed,
|
||||
// defer this combo and try again after the sibling combos catch up.
|
||||
@@ -5404,7 +5386,7 @@ export function createOmniRouteConfigHook(
|
||||
// exact warn message so per-endpoint fallbacks are preserved.
|
||||
const doModels = async (): Promise<void> => {
|
||||
try {
|
||||
localRawModels = await fetcher(baseURL, apiKey, 30_000);
|
||||
localRawModels = await fetcher(baseURL, apiKey, 10_000);
|
||||
} catch (err) {
|
||||
logAt(
|
||||
"error",
|
||||
|
||||
@@ -23,11 +23,27 @@ const ALIAS_UPPER_MAX_CHARS = 5;
|
||||
|
||||
// ── Auto Combo Types ─────────────────────────────────────────────────────
|
||||
|
||||
export type AutoVariant = "coding" | "fast" | "cheap" | "offline" | "smart" | "lkgp";
|
||||
export type AutoVariant =
|
||||
| "coding"
|
||||
| "fast"
|
||||
| "cheap"
|
||||
| "offline"
|
||||
| "smart"
|
||||
| "lkgp";
|
||||
|
||||
export const AUTO_VARIANTS: AutoVariant[] = ["coding", "fast", "cheap", "offline", "smart", "lkgp"];
|
||||
export const AUTO_VARIANTS: AutoVariant[] = [
|
||||
"coding",
|
||||
"fast",
|
||||
"cheap",
|
||||
"offline",
|
||||
"smart",
|
||||
"lkgp",
|
||||
];
|
||||
|
||||
export const AUTO_VARIANT_DESCRIPTIONS: Record<AutoVariant | "default", string> = {
|
||||
export const AUTO_VARIANT_DESCRIPTIONS: Record<
|
||||
AutoVariant | "default",
|
||||
string
|
||||
> = {
|
||||
default: "Best provider via scoring",
|
||||
coding: "Quality-first for code tasks",
|
||||
fast: "Latency-optimized routing",
|
||||
@@ -67,15 +83,24 @@ function titleCaseAlias(alias: string): string {
|
||||
* 3. Neither → undefined.
|
||||
*/
|
||||
export function shortProviderLabel(
|
||||
enrichment: { providerDisplayName?: string; providerAlias?: string } | undefined
|
||||
enrichment:
|
||||
| { providerDisplayName?: string; providerAlias?: string }
|
||||
| undefined,
|
||||
): string | undefined {
|
||||
if (!enrichment) return undefined;
|
||||
const raw =
|
||||
typeof enrichment.providerDisplayName === "string" ? enrichment.providerDisplayName.trim() : "";
|
||||
typeof enrichment.providerDisplayName === "string"
|
||||
? enrichment.providerDisplayName.trim()
|
||||
: "";
|
||||
if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw;
|
||||
const alias = typeof enrichment.providerAlias === "string" ? enrichment.providerAlias.trim() : "";
|
||||
const alias =
|
||||
typeof enrichment.providerAlias === "string"
|
||||
? enrichment.providerAlias.trim()
|
||||
: "";
|
||||
if (alias.length > 0) {
|
||||
return alias.length <= ALIAS_UPPER_MAX_CHARS ? alias.toUpperCase() : titleCaseAlias(alias);
|
||||
return alias.length <= ALIAS_UPPER_MAX_CHARS
|
||||
? alias.toUpperCase()
|
||||
: titleCaseAlias(alias);
|
||||
}
|
||||
// Long displayName with no alias to fall back on: keep the long label
|
||||
// rather than dropping the provider prefix entirely.
|
||||
@@ -106,33 +131,10 @@ export function normaliseFreeLabel(name: string): string {
|
||||
|
||||
// ── Free Budget Formatting ────────────────────────────────────────────────
|
||||
|
||||
/** Scales, largest first, so the unit is chosen by descending magnitude. */
|
||||
const TOKEN_UNITS = [
|
||||
[1e9, "B"],
|
||||
[1e6, "M"],
|
||||
[1e3, "K"],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Format a token count as a short magnitude string: `25M`, `1.5K`, `999`.
|
||||
*
|
||||
* The unit has to be picked from the value that will actually be *printed*,
|
||||
* not from the raw input. `toFixed(1)` rounds to the nearest tenth, so at the
|
||||
* K scale 999_950 and above render as `1000.0` — and by then the M branch has
|
||||
* already been skipped, producing `1000K` for a number that is `1M`. The same
|
||||
* carry turns just under a billion into `1000M`. When the rounded value reaches
|
||||
* the next scale, re-render at that scale instead.
|
||||
*/
|
||||
function fmtTokens(n: number): string {
|
||||
for (let i = 0; i < TOKEN_UNITS.length; i++) {
|
||||
const [scale, suffix] = TOKEN_UNITS[i]!;
|
||||
if (n < scale) continue;
|
||||
const value = Number((n / scale).toFixed(1));
|
||||
// `Number()` also drops a trailing `.0`, which the previous regex did.
|
||||
if (value < 1000 || i === 0) return `${value}${suffix}`;
|
||||
const [nextScale, nextSuffix] = TOKEN_UNITS[i - 1]!;
|
||||
return `${Number((n / nextScale).toFixed(1))}${nextSuffix}`;
|
||||
}
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B";
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
@@ -182,11 +184,15 @@ export function formatFreeBudget(params: {
|
||||
*/
|
||||
export function formatAutoComboName(
|
||||
variant: AutoVariant | undefined,
|
||||
candidateCount?: number
|
||||
candidateCount?: number,
|
||||
): string {
|
||||
const label = variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Default";
|
||||
const label = variant
|
||||
? variant.charAt(0).toUpperCase() + variant.slice(1)
|
||||
: "Default";
|
||||
const count =
|
||||
typeof candidateCount === "number" && candidateCount > 0 ? ` (${candidateCount}p)` : "";
|
||||
typeof candidateCount === "number" && candidateCount > 0
|
||||
? ` (${candidateCount}p)`
|
||||
: "";
|
||||
return `Auto: ${label}${count}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* Map gateway-reported OmniRoute inference telemetry onto the JSON/SSE
|
||||
* payload OpenCode already consumes. Prefer headers / usage fields from the
|
||||
* gateway. Never invent tok/s from tokens / latency (that includes TTFT).
|
||||
*/
|
||||
export type OmniRouteInferenceTelemetry = {
|
||||
costUsd?: number;
|
||||
tokensIn?: number;
|
||||
tokensOut?: number;
|
||||
tokensPerSecond?: number;
|
||||
ttftMs?: number;
|
||||
latencyMs?: number;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
const HEADER = {
|
||||
cost: "x-omniroute-response-cost",
|
||||
tokensIn: "x-omniroute-tokens-in",
|
||||
tokensOut: "x-omniroute-tokens-out",
|
||||
tokensPerSecond: "x-omniroute-tokens-per-second",
|
||||
ttftMs: "x-omniroute-ttft-ms",
|
||||
latencyMs: "x-omniroute-latency-ms",
|
||||
model: "x-omniroute-model",
|
||||
provider: "x-omniroute-provider",
|
||||
} as const;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readFiniteNumber(raw: string | null): number | undefined {
|
||||
if (raw == null) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function readPositiveNumber(raw: string | null): number | undefined {
|
||||
const parsed = readFiniteNumber(raw);
|
||||
if (parsed === undefined || parsed <= 0) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readNonNegativeInt(raw: string | null): number | undefined {
|
||||
const parsed = readFiniteNumber(raw);
|
||||
if (parsed === undefined || parsed < 0) return undefined;
|
||||
return Math.round(parsed);
|
||||
}
|
||||
|
||||
function readToken(raw: string | null): string | undefined {
|
||||
if (raw == null) return undefined;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
export function parseOmniRouteInferenceTelemetry(headers: Headers): OmniRouteInferenceTelemetry {
|
||||
const out: OmniRouteInferenceTelemetry = {};
|
||||
const cost = readFiniteNumber(headers.get(HEADER.cost));
|
||||
if (cost !== undefined && cost >= 0) out.costUsd = cost;
|
||||
const tokensIn = readNonNegativeInt(headers.get(HEADER.tokensIn));
|
||||
if (tokensIn !== undefined) out.tokensIn = tokensIn;
|
||||
const tokensOut = readNonNegativeInt(headers.get(HEADER.tokensOut));
|
||||
if (tokensOut !== undefined) out.tokensOut = tokensOut;
|
||||
const tps = readPositiveNumber(headers.get(HEADER.tokensPerSecond));
|
||||
if (tps !== undefined) out.tokensPerSecond = tps;
|
||||
const ttft = readPositiveNumber(headers.get(HEADER.ttftMs));
|
||||
if (ttft !== undefined) out.ttftMs = ttft;
|
||||
const latency = readPositiveNumber(headers.get(HEADER.latencyMs));
|
||||
if (latency !== undefined) out.latencyMs = latency;
|
||||
const model = readToken(headers.get(HEADER.model));
|
||||
if (model) out.model = model;
|
||||
const provider = readToken(headers.get(HEADER.provider));
|
||||
if (provider) out.provider = provider;
|
||||
return out;
|
||||
}
|
||||
|
||||
function telemetryFromUsage(usage: Record<string, unknown>): OmniRouteInferenceTelemetry {
|
||||
const out: OmniRouteInferenceTelemetry = {};
|
||||
const tps = usage.tokens_per_second;
|
||||
if (typeof tps === "number" && Number.isFinite(tps) && tps > 0) {
|
||||
out.tokensPerSecond = tps;
|
||||
}
|
||||
const ttft = usage.ttft_ms;
|
||||
if (typeof ttft === "number" && Number.isFinite(ttft) && ttft > 0) {
|
||||
out.ttftMs = ttft;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeTelemetry(
|
||||
base: OmniRouteInferenceTelemetry,
|
||||
extra: OmniRouteInferenceTelemetry,
|
||||
): OmniRouteInferenceTelemetry {
|
||||
return {
|
||||
...base,
|
||||
...Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
function isInferencePayload(payload: Record<string, unknown>): boolean {
|
||||
return (
|
||||
isRecord(payload.usage) ||
|
||||
Array.isArray(payload.choices) ||
|
||||
payload.object === "chat.completion" ||
|
||||
payload.object === "response" ||
|
||||
payload.type === "message" ||
|
||||
Array.isArray(payload.output)
|
||||
);
|
||||
}
|
||||
|
||||
function attachToUsage(
|
||||
usage: Record<string, unknown>,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): Record<string, unknown> {
|
||||
const next = { ...usage };
|
||||
if (
|
||||
telemetry.tokensPerSecond !== undefined &&
|
||||
(typeof next.tokens_per_second !== "number" || next.tokens_per_second <= 0)
|
||||
) {
|
||||
next.tokens_per_second = telemetry.tokensPerSecond;
|
||||
}
|
||||
if (telemetry.ttftMs !== undefined && (typeof next.ttft_ms !== "number" || next.ttft_ms <= 0)) {
|
||||
next.ttft_ms = telemetry.ttftMs;
|
||||
}
|
||||
if (telemetry.costUsd !== undefined && typeof next.cost !== "number") {
|
||||
next.cost = telemetry.costUsd;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function attachOmniRouteTelemetryToPayload(
|
||||
payload: unknown,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): unknown {
|
||||
if (!isRecord(payload) || !isInferencePayload(payload)) {
|
||||
return payload;
|
||||
}
|
||||
const next: Record<string, unknown> = { ...payload };
|
||||
if (telemetry.model) {
|
||||
next.model = telemetry.model;
|
||||
}
|
||||
if (isRecord(next.usage)) {
|
||||
next.usage = attachToUsage(next.usage, mergeTelemetry(telemetry, telemetryFromUsage(next.usage)));
|
||||
}
|
||||
if (isRecord(next.response) && isRecord(next.response.usage)) {
|
||||
next.response = {
|
||||
...next.response,
|
||||
usage: attachToUsage(
|
||||
next.response.usage,
|
||||
mergeTelemetry(telemetry, telemetryFromUsage(next.response.usage)),
|
||||
),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function attachOmniRouteTelemetryToSseLine(
|
||||
line: string,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): string {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) {
|
||||
return line;
|
||||
}
|
||||
const jsonText = trimmed.slice("data:".length).trim();
|
||||
if (!jsonText.startsWith("{")) {
|
||||
return line;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText) as unknown;
|
||||
const updated = attachOmniRouteTelemetryToPayload(parsed, telemetry);
|
||||
if (updated === parsed) {
|
||||
return line;
|
||||
}
|
||||
const prefix = line.slice(0, line.indexOf(jsonText));
|
||||
const suffix = line.endsWith("\r") ? "\r" : "";
|
||||
return `${prefix}${JSON.stringify(updated)}${suffix}`;
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyOmniRouteInferenceTelemetry(response: Response): Promise<Response> {
|
||||
const telemetry = parseOmniRouteInferenceTelemetry(response.headers);
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("text/event-stream") && response.body) {
|
||||
return new Response(mapSseBody(response.body, telemetry), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
if (!contentType.includes("json")) {
|
||||
return response;
|
||||
}
|
||||
const text = await response.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
const next = attachOmniRouteTelemetryToPayload(parsed, telemetry);
|
||||
if (next === parsed) {
|
||||
return new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(next), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
function mapSseBody(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
telemetry: OmniRouteInferenceTelemetry,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let pending = "";
|
||||
let live = { ...telemetry };
|
||||
return body.pipeThrough(
|
||||
new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
pending += decoder.decode(chunk, { stream: true });
|
||||
const lines = pending.split("\n");
|
||||
pending = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
controller.enqueue(encoder.encode(`${attachOmniRouteTelemetryToSseLine(line, live)}\n`));
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
if (pending.length > 0) {
|
||||
controller.enqueue(encoder.encode(attachOmniRouteTelemetryToSseLine(pending, live)));
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -641,13 +641,13 @@ test("models(): combos fetcher receives the resolved baseURL + apiKey", async ()
|
||||
|
||||
test("models(): nested combo-ref context is the min of nested + raw members", async () => {
|
||||
// Top-level combo MASTER-LIGHT has 1 raw model (claude-primary, 200k)
|
||||
// and 2 combo-refs: LEGACY (8k member) and KIRO (32k member). The OLD
|
||||
// and 2 combo-refs: OldLLM (8k member) and KIRO (32k member). The OLD
|
||||
// plugin would advertise 200k (only the raw model); the fix should
|
||||
// make it advertise 8k (the bottleneck across the member graph).
|
||||
const modelsFetcher = stubModelsFetcher([
|
||||
MODEL_PRIMARY,
|
||||
{
|
||||
id: "legacy-member-1",
|
||||
id: "oldllm-member-1",
|
||||
context_length: 8_000,
|
||||
max_output_tokens: 4_000,
|
||||
capabilities: {
|
||||
@@ -677,9 +677,9 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
|
||||
]);
|
||||
const combosFetcher = stubCombosFetcher([
|
||||
{
|
||||
id: "legacy",
|
||||
name: "LEGACY",
|
||||
models: [{ id: "s1", kind: "model", model: "legacy-member-1", weight: 100 }],
|
||||
id: "oldllm",
|
||||
name: "OldLLM",
|
||||
models: [{ id: "s1", kind: "model", model: "oldllm-member-1", weight: 100 }],
|
||||
},
|
||||
{
|
||||
id: "kiro",
|
||||
@@ -691,7 +691,7 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
|
||||
name: "MASTER-LIGHT",
|
||||
models: [
|
||||
{ id: "r1", kind: "model", model: "claude-primary", weight: 50 },
|
||||
{ id: "r2", kind: "combo-ref", comboName: "LEGACY", weight: 25 },
|
||||
{ id: "r2", kind: "combo-ref", comboName: "OldLLM", weight: 25 },
|
||||
{ id: "r3", kind: "combo-ref", comboName: "KIRO", weight: 25 },
|
||||
],
|
||||
},
|
||||
@@ -706,6 +706,6 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
|
||||
assert.equal(
|
||||
masterLight.limit.context,
|
||||
8_000,
|
||||
`expected 8_000 (LEGACY bottleneck), got ${masterLight.limit.context}`
|
||||
`expected 8_000 (OldLLM bottleneck), got ${masterLight.limit.context}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* effort_tiers loop — plugin maps server-declared tiers to ModelV2 variants.
|
||||
* Blind mapping (I3): no owned_by/provider knowledge here — the SERVER gates
|
||||
* eligibility (shouldExposeSyncedEffortVariants). Absence semantics (M3):
|
||||
* no tiers => NO variants key at all (an empty object would also kill
|
||||
* opencode's own fallback for non-tiered models).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mapRawModelToModelV2, type OmniRouteRawModelEntry } from "../src/index.js";
|
||||
|
||||
const CTX = { providerId: "omniroute", baseURL: "http://127.0.0.1:20128" } as const;
|
||||
|
||||
test("maps declared tiers to reasoningEffort variants", () => {
|
||||
const raw: OmniRouteRawModelEntry = {
|
||||
id: "oc/x-preview-f-free",
|
||||
owned_by: "opencode",
|
||||
capabilities: { reasoning: true, effort_tiers: ["low", "high", "max"] },
|
||||
};
|
||||
const model = mapRawModelToModelV2(raw, { ...CTX });
|
||||
const variants = (model as unknown as Record<string, unknown>).variants as
|
||||
Record<string, Record<string, unknown>> | undefined;
|
||||
assert.ok(variants, "variants key present when tiers declared");
|
||||
assert.deepEqual(Object.keys(variants).sort(), ["high", "low", "max"]);
|
||||
assert.deepEqual(variants.max, { reasoningEffort: "max" });
|
||||
assert.deepEqual(variants.low, { reasoningEffort: "low" });
|
||||
});
|
||||
|
||||
test("no tiers => NO variants key (not an empty object)", () => {
|
||||
const raw: OmniRouteRawModelEntry = {
|
||||
id: "plain-model",
|
||||
capabilities: { reasoning: true },
|
||||
};
|
||||
const model = mapRawModelToModelV2(raw, { ...CTX }) as unknown as Record<string, unknown>;
|
||||
assert.equal("variants" in model, false);
|
||||
});
|
||||
|
||||
test("empty or malformed tiers array => NO variants key", () => {
|
||||
const empty = mapRawModelToModelV2(
|
||||
{ id: "m", capabilities: { effort_tiers: [] } },
|
||||
{ ...CTX }
|
||||
) as unknown as Record<string, unknown>;
|
||||
assert.equal("variants" in empty, false);
|
||||
|
||||
const junk = mapRawModelToModelV2(
|
||||
{ id: "m", capabilities: { effort_tiers: [42, null, "ok"] as unknown as string[] } },
|
||||
{ ...CTX }
|
||||
) as unknown as Record<string, unknown>;
|
||||
const variants = junk.variants as Record<string, Record<string, unknown>> | undefined;
|
||||
assert.deepEqual(Object.keys(variants ?? {}), ["ok"], "non-string tokens dropped");
|
||||
});
|
||||
|
||||
test("static registry entry WITH tiers also gets variants (N1 blast radius)", () => {
|
||||
const raw: OmniRouteRawModelEntry = {
|
||||
id: "some-static-model",
|
||||
owned_by: "registry",
|
||||
capabilities: { effort_tiers: ["minimal", "high"] },
|
||||
};
|
||||
const model = mapRawModelToModelV2(raw, { ...CTX }) as unknown as Record<string, unknown>;
|
||||
const variants = model.variants as Record<string, Record<string, unknown>> | undefined;
|
||||
assert.deepEqual(Object.keys(variants ?? {}).sort(), ["high", "minimal"]);
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Magnitude-crossover regression for the free-budget suffix
|
||||
* (`formatFreeBudget` -> `fmtTokens` in @omniroute/opencode-plugin/src/naming.ts).
|
||||
*
|
||||
* `fmtTokens` picked its unit from the raw input and then rounded with
|
||||
* `toFixed(1)`. Rounding can carry a value into the next magnitude *after* that
|
||||
* branch has been skipped, so 999_950..999_999 rendered as "1000K" rather than
|
||||
* "1M", and just under a billion rendered as "1000M" rather than "1B".
|
||||
*
|
||||
* These budgets are not always round numbers: `monthlyTokens` is derived from the
|
||||
* remote Radar feed (`tokensPerMonth`) and can be replaced wholesale by a
|
||||
* user-local override, so the crossover band is reachable with real data.
|
||||
*
|
||||
* Kept in its own file rather than added to naming.test.ts so this does not
|
||||
* collide with the coverage being added for `formatFreeBudget` in #11660.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { formatFreeBudget } from "../src/naming.js";
|
||||
|
||||
/** `recurring-daily` is the shortest path from a token count to a rendered suffix. */
|
||||
const daily = (monthlyTokens: number) =>
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens }).replace(" tokens/day", "");
|
||||
|
||||
test("fmtTokens: a rounded K value that reaches 1000 is promoted to M", () => {
|
||||
// 999_950 is the true boundary, not 999_999: toFixed(1) rounds to the nearest
|
||||
// tenth, so 999.95K is the first value that carries to "1000.0".
|
||||
assert.equal(daily(999_950), "1M");
|
||||
assert.equal(daily(999_999), "1M");
|
||||
});
|
||||
|
||||
test("fmtTokens: a rounded M value that reaches 1000 is promoted to B", () => {
|
||||
assert.equal(daily(999_950_000), "1B");
|
||||
assert.equal(daily(999_999_999), "1B");
|
||||
});
|
||||
|
||||
test("fmtTokens: values just below the rounding boundary keep their own unit", () => {
|
||||
// The promotion must not fire early — 999.9K still rounds to 999.9, not 1000.
|
||||
assert.equal(daily(999_949), "999.9K");
|
||||
assert.equal(daily(999_499), "999.5K");
|
||||
assert.equal(daily(999_499_999), "999.5M");
|
||||
});
|
||||
|
||||
test("fmtTokens: ordinary magnitudes are unchanged", () => {
|
||||
assert.equal(daily(0), "0");
|
||||
assert.equal(daily(999), "999");
|
||||
assert.equal(daily(1_000), "1K");
|
||||
assert.equal(daily(1_500), "1.5K");
|
||||
assert.equal(daily(1_000_000), "1M");
|
||||
assert.equal(daily(1_500_000), "1.5M");
|
||||
assert.equal(daily(25_000_000), "25M");
|
||||
assert.equal(daily(1_234_567), "1.2M");
|
||||
assert.equal(daily(1_000_000_000), "1B");
|
||||
assert.equal(daily(2_500_000_000), "2.5B");
|
||||
});
|
||||
|
||||
test("fmtTokens: B is the top unit, so a carry there has nowhere to go", () => {
|
||||
// Deliberately pinned: promoting past B would need a unit that does not exist,
|
||||
// so "1000B" is the intended output rather than an oversight.
|
||||
assert.equal(daily(999_999_999_999), "1000B");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: the promotion applies to every token-bearing branch", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-monthly", monthlyTokens: 999_999 }),
|
||||
"1M tokens/month"
|
||||
);
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-credit", creditTokens: 999_999 }),
|
||||
"1M credits"
|
||||
);
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "one-time-initial", creditTokens: 999_999 }),
|
||||
"1M credits (one-time)"
|
||||
);
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { defaultOmniRouteModelsFetcher } from "../src/index.js";
|
||||
|
||||
test("defaultOmniRouteModelsFetcher attaches statusCode on HTTP 401", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ error: "authentication expired" }), {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key"),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof Error);
|
||||
const rec = err as Error & { statusCode?: number; status?: number };
|
||||
assert.equal(rec.statusCode, 401);
|
||||
assert.equal(rec.status, 401);
|
||||
assert.match(rec.message, /401/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteModelsFetcher default timeout is 30s", async () => {
|
||||
const original = globalThis.fetch;
|
||||
let signal: AbortSignal | undefined;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
signal = init?.signal ?? undefined;
|
||||
return new Response(JSON.stringify({ object: "list", data: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key");
|
||||
assert.equal(signal instanceof AbortSignal, true);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Tests for `formatFreeBudget` (@omniroute/opencode-plugin/src/naming.ts):
|
||||
* formats a free-tier model's budget info into a short human-readable
|
||||
* suffix, branching on `freeType`.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { formatFreeBudget, type FreeModelFreeType } from "../src/naming.js";
|
||||
|
||||
test("formatFreeBudget: recurring-daily formats tokens/day", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 25_000_000 }),
|
||||
"25M tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: recurring-monthly formats tokens/month", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-monthly", monthlyTokens: 1_000_000 }),
|
||||
"1M tokens/month"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: recurring-credit formats credits", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-credit", creditTokens: 10_000_000 }),
|
||||
"10M credits"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: one-time-initial formats credits with (one-time) suffix", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "one-time-initial", creditTokens: 1_000_000 }),
|
||||
"1M credits (one-time)"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: keyless has no token/credit args", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "keyless" }), "(keyless)");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: discontinued has no token/credit args", () => {
|
||||
assert.equal(formatFreeBudget({ freeType: "discontinued" }), "(discontinued)");
|
||||
});
|
||||
|
||||
test("formatFreeBudget: missing token/credit counts default to 0", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily" }),
|
||||
"0 tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: unrecognised freeType falls through to the default branch", () => {
|
||||
// `freeType` is populated from catalog data at runtime, so a value the
|
||||
// build doesn't know about is reachable even though TypeScript treats the
|
||||
// `default:` arm as dead code for a well-typed caller.
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "some-future-type" as FreeModelFreeType }),
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: sub-1K token count is not abbreviated", () => {
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 500 }),
|
||||
"500 tokens/day"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatFreeBudget: the 999_999 rounding wart is fixed — promotes to 1M", () => {
|
||||
// `toFixed(1)` rounds 999999/1e3 up to "1000.0" before the `>= 1e6` threshold
|
||||
// check has a chance to apply. fmtTokens now promotes a rounded-up "1000" in
|
||||
// any unit to the next unit up, so this correctly reads "1M" instead of the
|
||||
// old "1000K" wart.
|
||||
assert.equal(
|
||||
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 999_999 }),
|
||||
"1M tokens/day"
|
||||
);
|
||||
});
|
||||
@@ -104,10 +104,7 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
|
||||
// #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.
|
||||
// #10345/#10821: bare combo ids (owned_by: "combo") stay unprefixed —
|
||||
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under the
|
||||
// plugin provider, so `claude-primary` here carries no provider prefix.
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -162,15 +159,11 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
// 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.
|
||||
// #10345/#10821: bare **combo** ids (owned_by: "combo", e.g.
|
||||
// "claude-primary") must also stay unprefixed — OpenCode looks up
|
||||
// `-m <plugin>/<combo>` as model id `<combo>` under the plugin provider.
|
||||
const claude = out["claude-primary"];
|
||||
const claude = out["omniroute/claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
// `mapRawModelToModelV2` leaves bare combo ids unprefixed (see
|
||||
// src/index.ts mapRawModelToModelV2) so OC's `-m <plugin>/<combo>` lookup
|
||||
// resolves the combo id directly.
|
||||
assert.equal(claude.id, "claude-primary");
|
||||
// `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.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
applyOmniRouteInferenceTelemetry,
|
||||
attachOmniRouteTelemetryToPayload,
|
||||
attachOmniRouteTelemetryToSseLine,
|
||||
parseOmniRouteInferenceTelemetry,
|
||||
} from "../src/telemetry.js";
|
||||
|
||||
test("parseOmniRouteInferenceTelemetry: copies cost, tokens, tok/s, winning model", () => {
|
||||
const headers = new Headers({
|
||||
"X-OmniRoute-Response-Cost": "0.0123",
|
||||
"X-OmniRoute-Tokens-In": "10",
|
||||
"X-OmniRoute-Tokens-Out": "200",
|
||||
"X-OmniRoute-Tokens-Per-Second": "100.5",
|
||||
"X-OmniRoute-Ttft-Ms": "300",
|
||||
"X-OmniRoute-Latency-Ms": "2300",
|
||||
"X-OmniRoute-Model": "winner-model",
|
||||
"X-OmniRoute-Provider": "openai",
|
||||
});
|
||||
const got = parseOmniRouteInferenceTelemetry(headers);
|
||||
assert.equal(got.costUsd, 0.0123);
|
||||
assert.equal(got.tokensIn, 10);
|
||||
assert.equal(got.tokensOut, 200);
|
||||
assert.equal(got.tokensPerSecond, 100.5);
|
||||
assert.equal(got.ttftMs, 300);
|
||||
assert.equal(got.model, "winner-model");
|
||||
assert.equal(got.provider, "openai");
|
||||
});
|
||||
|
||||
test("parseOmniRouteInferenceTelemetry: omits tok/s when header missing (do not invent from latency)", () => {
|
||||
const headers = new Headers({
|
||||
"X-OmniRoute-Tokens-Out": "200",
|
||||
"X-OmniRoute-Latency-Ms": "2000",
|
||||
});
|
||||
const got = parseOmniRouteInferenceTelemetry(headers);
|
||||
assert.equal(got.tokensPerSecond, undefined);
|
||||
assert.equal(got.tokensOut, 200);
|
||||
const payload = attachOmniRouteTelemetryToPayload(
|
||||
{ object: "chat.completion", usage: { prompt_tokens: 10, completion_tokens: 200 } },
|
||||
got,
|
||||
) as { usage: { tokens_per_second?: number } };
|
||||
assert.equal(payload.usage.tokens_per_second, undefined);
|
||||
});
|
||||
|
||||
test("attachOmniRouteTelemetryToPayload: writes usage.tokens_per_second and winning model", () => {
|
||||
const got = attachOmniRouteTelemetryToPayload(
|
||||
{
|
||||
object: "chat.completion",
|
||||
model: "combo/auto",
|
||||
usage: { prompt_tokens: 10, completion_tokens: 200 },
|
||||
},
|
||||
{ tokensPerSecond: 80, ttftMs: 250, costUsd: 0, model: "gpt-winner" },
|
||||
) as {
|
||||
model: string;
|
||||
usage: { tokens_per_second: number; ttft_ms: number; cost: number };
|
||||
};
|
||||
assert.equal(got.model, "gpt-winner");
|
||||
assert.equal(got.usage.tokens_per_second, 80);
|
||||
assert.equal(got.usage.ttft_ms, 250);
|
||||
assert.equal(got.usage.cost, 0);
|
||||
});
|
||||
|
||||
test("attachOmniRouteTelemetryToPayload: does not mutate /v1/models catalog JSON", () => {
|
||||
const catalog = { object: "list", data: [{ id: "m1" }] };
|
||||
const got = attachOmniRouteTelemetryToPayload(catalog, {
|
||||
tokensPerSecond: 99,
|
||||
model: "should-not-apply",
|
||||
});
|
||||
assert.deepEqual(got, catalog);
|
||||
});
|
||||
|
||||
test("attachOmniRouteTelemetryToSseLine: patches terminal usage data line", () => {
|
||||
const line =
|
||||
'data: {"object":"chat.completion.chunk","usage":{"completion_tokens":200}}';
|
||||
const got = attachOmniRouteTelemetryToSseLine(line, { tokensPerSecond: 50 });
|
||||
assert.match(got, /"tokens_per_second":50/);
|
||||
assert.match(got, /^data: /);
|
||||
});
|
||||
|
||||
test("applyOmniRouteInferenceTelemetry: JSON response gets header tok/s", async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
object: "chat.completion",
|
||||
model: "combo/auto",
|
||||
usage: { prompt_tokens: 1, completion_tokens: 20 },
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-OmniRoute-Tokens-Per-Second": "40",
|
||||
"X-OmniRoute-Model": "winner",
|
||||
},
|
||||
},
|
||||
);
|
||||
const next = await applyOmniRouteInferenceTelemetry(response);
|
||||
const body = JSON.parse(await next.text()) as {
|
||||
model: string;
|
||||
usage: { tokens_per_second: number };
|
||||
};
|
||||
assert.equal(body.model, "winner");
|
||||
assert.equal(body.usage.tokens_per_second, 40);
|
||||
});
|
||||
115
AGENTS.md
115
AGENTS.md
@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 356 LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 348 LLM providers, auto-fallback.
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -56,9 +56,9 @@ Repository map and Reference Documentation sections below.
|
||||
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
||||
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (169 migrations) |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (157 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
|
||||
| MCP Server | `open-sse/mcp-server/` | 110 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
| Skills | `src/lib/skills/` | Extensible skill framework |
|
||||
| Memory | `src/lib/memory/` | Persistent conversational memory |
|
||||
@@ -83,7 +83,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`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-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 16-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`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-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 14-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
|
||||
|
||||
---
|
||||
|
||||
@@ -110,36 +110,26 @@ upstream/service level, so one unhealthy provider does not slow down every reque
|
||||
- Shared wrappers: `open-sse/services/accountFallback.ts`
|
||||
- Persisted state table: `domain_circuit_breakers`
|
||||
|
||||
**States** (4 — `src/shared/utils/circuitBreaker.ts`):
|
||||
**States**:
|
||||
|
||||
- `CLOSED`: normal traffic is allowed.
|
||||
- `DEGRADED`: early-warning band — failures crossed the degradation threshold but not the
|
||||
breaker threshold yet; traffic still flows, dashboards show the warning.
|
||||
- `OPEN`: provider is temporarily blocked; callers get a provider-circuit-open response
|
||||
or combo routing skips to another target.
|
||||
- `HALF_OPEN`: reset timeout has elapsed; allow a probe request. Success closes the
|
||||
breaker, failure opens it again.
|
||||
|
||||
**Defaults** (`open-sse/config/constants.ts` → `PROVIDER_PROFILES`, consumed via
|
||||
`DEFAULT_RESILIENCE_SETTINGS.providerBreaker` in `src/lib/resilience/settings.ts` →
|
||||
`getCircuitBreaker(provider, …)` in `src/sse/handlers/chatHelpers.ts`). The whole-provider
|
||||
breaker runs on `circuitBreakerThreshold` / `circuitBreakerReset`:
|
||||
**Defaults** (`open-sse/config/constants.ts` → `PROVIDER_PROFILES`). Two thresholds live side by
|
||||
side — do not confuse them:
|
||||
|
||||
| Profile | degrades at | opens at (`circuitBreakerThreshold`) | reset (`circuitBreakerReset`) |
|
||||
| ------- | ----------: | -----------------------------------: | ----------------------------: |
|
||||
| OAuth | `5` | `8` | `60s` |
|
||||
| API key | `7` | `12` | `30s` |
|
||||
| Local | (derived) | `2` | `15s` |
|
||||
| Profile | `providerFailureThreshold` (whole provider) | `providerCooldownMs` | `circuitBreakerThreshold` (one connection) | `circuitBreakerReset` |
|
||||
| ------- | ------------------------------------------: | -------------------: | -----------------------------------------: | --------------------: |
|
||||
| OAuth | `10` | `5min` | `8` | `60s` |
|
||||
| API key | `15` | `10min` | `12` | `30s` |
|
||||
| Local | `2` | `1min` | `2` | `15s` |
|
||||
|
||||
`PROVIDER_PROFILES` also defines `providerFailureThreshold` (10/15/2),
|
||||
`providerFailureWindowMs` (15/30/5 min) and `providerCooldownMs` (5/10/1 min): these power the
|
||||
**window gate of the opt-in global Provider Cooldown** (`PROVIDER_COOLDOWN_ENABLED`, default
|
||||
off) — a provider-level entry in `open-sse/services/providerCooldownTracker.ts` only counts as
|
||||
cooling after `providerFailureThreshold` failures inside `providerFailureWindowMs`, and then
|
||||
cools for `providerCooldownMs`. They are NOT the live breaker's thresholds — do not tune them
|
||||
expecting breaker behavior. Every default is overridable through the
|
||||
`OMNIROUTE_PROVIDER_BREAKER_*` and `OMNIROUTE_CIRCUIT_BREAKER_*` env vars; the
|
||||
runtime-accurate reference table lives in `docs/architecture/RESILIENCE_GUIDE.md`.
|
||||
The provider-level thresholds were scaled up for deployments with 500+ connections (OAuth was
|
||||
`3`, API key was `5`); every default is overridable through the `OMNIROUTE_PROVIDER_BREAKER_*`
|
||||
and `OMNIROUTE_CIRCUIT_BREAKER_*` env vars.
|
||||
|
||||
Only provider-level failure statuses should trip the provider breaker:
|
||||
|
||||
@@ -207,7 +197,7 @@ baseCooldownMs * 2 ** failureIndex;
|
||||
The anti-thundering-herd guard prevents concurrent failures on the same connection from
|
||||
repeatedly extending the cooldown or double-incrementing `backoffLevel`.
|
||||
|
||||
Terminal states are not cooldowns. `banned`, `expired` (which becomes terminal only after N bounded retries via `EXPIRED_RETRY_MAX`), and `credits_exhausted` are
|
||||
Terminal states are not cooldowns. `banned`, `expired`, and `credits_exhausted` are
|
||||
intended to stay unavailable until credentials/settings change or an operator resets
|
||||
them. Do not overwrite terminal states with transient cooldown state.
|
||||
|
||||
@@ -252,7 +242,7 @@ Read the nearest `AGENTS.md` and the linked deep-dive before making a non-trivia
|
||||
| Streaming request handling | `open-sse/handlers/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Provider execution and translation | `open-sse/executors/`, `open-sse/translator/` | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Routing and resilience | `open-sse/services/` | [`open-sse/services/AGENTS.md`](open-sse/services/AGENTS.md), [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Database and migrations | `src/lib/db/`, `src/lib/db/migrations/` | [`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md) |
|
||||
| Database and migrations | `src/lib/db/`, `db/migrations/` | [`src/lib/db/AGENTS.md`](src/lib/db/AGENTS.md) |
|
||||
| Domain policy | `src/domain/` | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| MCP and A2A | `open-sse/mcp-server/`, `src/lib/a2a/` | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md), [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| Agent features | `src/lib/{acp,memory,skills,cloudAgent}/` | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md), [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
@@ -264,13 +254,13 @@ Read the nearest `AGENTS.md` and the linked deep-dive before making a non-trivia
|
||||
## File placement & repo-root hygiene
|
||||
|
||||
- **Test files**: ALL unit tests, integration tests, ecosystem tests, or Vitest files MUST strictly be placed within the `tests/` directory (e.g., `tests/unit/`, `tests/integration/`). NEVER create test files in the project root (`/`).
|
||||
- **Scripts and utilities**: ALL maintenance, debugging, generation, or experimental scripts (`.cjs`, `.mjs`, `.js`, `.ts`) MUST be placed strictly inside one of the `scripts/` subfolders (`build/`, `dev/`, `check/`, `docs/`, `i18n/`, `ad-hoc/`, `quality/`, `release/`, `ci/`, `ops/`, `perf/`, `research/`, `sre/`, `vps/`, `homolog/`, `packs/`, `skills/`, `test/`, `cli/`, `compression/`, `compression-eval/`, `devin-bridge/`, `docker/`, `features/`, `router-eval/`). One-shot or experimental code goes under `scripts/ad-hoc/`. NEVER dump loose scripts in the project root (`/`) or the top-level `scripts/` folder.
|
||||
- **Scripts and utilities**: ALL maintenance, debugging, generation, or experimental scripts (`.cjs`, `.mjs`, `.js`, `.ts`) MUST be placed strictly inside one of the `scripts/` subfolders (`build/`, `dev/`, `check/`, `docs/`, `i18n/`, `ad-hoc/`, `quality/`, `release/`, `ci/`, `ops/`, `perf/`, `research/`, `sre/`, `vps/`, `homolog/`, `raycast/`, `skills/`, `test/`, `cli/`, `compression/`, `compression-eval/`, `devin-bridge/`, `docker/`, `features/`, `router-eval/`). One-shot or experimental code goes under `scripts/ad-hoc/`. NEVER dump loose scripts in the project root (`/`) or the top-level `scripts/` folder.
|
||||
|
||||
**The project root MUST ONLY contain:**
|
||||
|
||||
- Configuration files (`vitest.config.ts`, `next.config.mjs`, `eslint.config.mjs`, `tsconfig*.json`, `playwright.config.ts`, `prettier.config.mjs`, `postcss.config.mjs`, `sonar-project.properties`, `fly.toml`, `docker-compose*.yml`, `Dockerfile`)
|
||||
- Dependency files (`package.json`, `package-lock.json`)
|
||||
- Documentation files (`README.md`, `CHANGELOG.md`, `ROADMAP.md`, `LICENSE`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `llm.txt`)
|
||||
- Documentation files (`README.md`, `CHANGELOG.md`, `ROADMAP.md`, `LICENSE`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `llm.txt`, `Tuto_Qdrant.md`)
|
||||
- CI/CD files and ignore definitions (`.gitignore`, `.dockerignore`, `.npmignore`, `.npmrc`, `.node-version`, `.nvmrc`, `.env.example`)
|
||||
|
||||
When creating _any_ validation tests or one-off logic scripts, default to `scripts/ad-hoc/` or `tests/unit/` according to your goals. Do not pollute the `/` root context.
|
||||
@@ -299,7 +289,8 @@ When creating _any_ validation tests or one-off logic scripts, default to `scrip
|
||||
### Database
|
||||
|
||||
- **Always** go through `src/lib/db/` domain modules — **never** write raw SQL in routes or handlers
|
||||
- **Never** barrel-import from `localDb.ts` — import specific `src/lib/db/*` modules
|
||||
- **Never** add logic to `src/lib/localDb.ts` (re-export layer only)
|
||||
- **Never** barrel-import from `localDb.ts` — import specific `db/` modules instead
|
||||
- DB singleton: `getDbInstance()` from `src/lib/db/core.ts` (WAL journaling)
|
||||
- Migrations: `src/lib/db/migrations/` — versioned SQL files, idempotent, run in transactions
|
||||
|
||||
@@ -343,7 +334,6 @@ Documentation must describe verified behavior, not plausible behavior.
|
||||
|
||||
### Adding a New Provider
|
||||
|
||||
0. Check `docs/reference/REMOVED_PROVIDERS.md` first — providers removed at their operator's request must never be reintroduced (guarded by `tests/unit/removed-providers-blocklist.test.ts`)
|
||||
1. Register in `src/shared/constants/providers.ts` (Zod-validated at load)
|
||||
2. Add executor in `open-sse/executors/` if custom logic needed (extend `BaseExecutor`)
|
||||
3. Add translator in `open-sse/translator/` if non-OpenAI format
|
||||
@@ -365,18 +355,19 @@ Documentation must describe verified behavior, not plausible behavior.
|
||||
1. Create `src/lib/db/yourModule.ts` — import `getDbInstance` from `./core.ts`
|
||||
2. Export CRUD functions for your domain table(s)
|
||||
3. Add migration in `src/lib/db/migrations/` if new tables needed
|
||||
4. Write tests
|
||||
4. Re-export from `src/lib/localDb.ts` (add to the re-export list only)
|
||||
5. Write tests
|
||||
|
||||
### Adding a New MCP Tool
|
||||
|
||||
1. Add tool definition in `open-sse/mcp-server/tools/` with Zod input schema + async handler
|
||||
2. Register in tool set (wired by `createMcpServer()`)
|
||||
3. Assign to appropriate scope(s)
|
||||
4. Write tests (tool invocation logged to the `mcp_tool_audit` table)
|
||||
4. Write tests (tool invocation logged to `mcp_audit` table)
|
||||
|
||||
### Adding a New A2A Skill
|
||||
|
||||
1. Create skill in `src/lib/a2a/skills/` (6 already exist: smart-routing, quota-management, provider-discovery, cost-analysis, health-report, list-capabilities)
|
||||
1. Create skill in `src/lib/a2a/skills/` (5 already exist: smart-routing, quota-management, provider-discovery, cost-analysis, health-report)
|
||||
2. Skill receives task context (messages, metadata) → returns structured result
|
||||
3. Register in `A2A_SKILL_HANDLERS` in `src/lib/a2a/taskExecution.ts`
|
||||
4. Expose in `src/app/.well-known/agent.json/route.ts` (Agent Card)
|
||||
@@ -385,7 +376,7 @@ Documentation must describe verified behavior, not plausible behavior.
|
||||
|
||||
### Adding a New Cloud Agent
|
||||
|
||||
1. Create agent class in `src/lib/cloudAgent/agents/` extending `CloudAgentBase` (4 already exist: codex-cloud, devin, jules, cursor-cloud)
|
||||
1. Create agent class in `src/lib/cloudAgent/agents/` extending `CloudAgentBase` (3 already exist: codex-cloud, devin, jules)
|
||||
2. Implement `createTask`, `getStatus`, `approvePlan`, `sendMessage`, `listSources`
|
||||
3. Register in `src/lib/cloudAgent/registry.ts`
|
||||
4. Add OAuth/credentials handling if needed (`src/lib/oauth/providers/`)
|
||||
@@ -396,7 +387,7 @@ Documentation must describe verified behavior, not plausible behavior.
|
||||
1. Create installer in `src/lib/services/installers/{name}.ts` modeled on `ninerouter.ts` (use `runNpm` from `installers/utils.ts` — no shell interpolation, hard rule #13).
|
||||
2. Register the service in `src/lib/services/bootstrap.ts` (add to `SERVICES[]` array and extend `buildSpawnArgsFactory()`).
|
||||
3. Add a DB seed row for the new service in `src/lib/db/migrations/` (`version_manager` table, `status='not_installed'`, `auto_start=0`).
|
||||
4. Create 8 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`, `auto-restart-adopted`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`.
|
||||
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`.
|
||||
@@ -408,9 +399,6 @@ Documentation must describe verified behavior, not plausible behavior.
|
||||
- Eval suite: `src/lib/evals/` → docs: `docs/frameworks/EVALS.md`
|
||||
- Skill (sandbox): `src/lib/skills/` → docs: `docs/frameworks/SKILLS.md`
|
||||
- Webhook event: `src/lib/webhookDispatcher.ts` → docs: `docs/frameworks/WEBHOOKS.md`
|
||||
- Log-export destination: add `src/lib/logExport/destinations/<name>.ts` + one line in
|
||||
`src/lib/logExport/registry.ts` → docs: `docs/frameworks/LOG-EXPORT.md`. The runner, REST layer
|
||||
and dashboard form all read the registry, so nothing else changes.
|
||||
|
||||
---
|
||||
|
||||
@@ -423,7 +411,7 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
|
||||
| Architecture | `docs/architecture/ARCHITECTURE.md` |
|
||||
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
|
||||
| Auto-Combo (16-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.md` |
|
||||
| Auto-Combo (14-factor scoring, 19 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` |
|
||||
@@ -436,7 +424,6 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
| Evals | `docs/frameworks/EVALS.md` |
|
||||
| Compliance / audit | `docs/security/COMPLIANCE.md` |
|
||||
| Webhooks | `docs/frameworks/WEBHOOKS.md` |
|
||||
| Log export (call logs → BigQuery/…) | `docs/frameworks/LOG-EXPORT.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` |
|
||||
@@ -449,7 +436,7 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
| VS Code Copilot Chat (OmniCopilot extension) | `docs/guides/VSCODE-COPILOT.md` |
|
||||
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
|
||||
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
| Quality gates (~90 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
|
||||
| Quality gates (~80 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
|
||||
|
||||
---
|
||||
|
||||
@@ -495,12 +482,6 @@ Why this matters: fixing bug A while opening bug B is worse than not fixing at a
|
||||
pipeline, and A2A skills.
|
||||
- Do not close a contributor pull request after using its code; merge it through GitHub so
|
||||
the contributor receives credit.
|
||||
- **Never merge a PR that touches an agent-instruction surface without explicit operator
|
||||
approval** — `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `llm.txt` (+ mirrors) and
|
||||
`skills/**/SKILL.md` are executed as authority by every AI session; a merged instruction
|
||||
compromises every future agent run. Check with `gh pr diff <N> --name-only` before any
|
||||
merge. Incident record: PR #11770 (2026-09-01) told agents to execute a third-party
|
||||
setup script and was swept in by a merge campaign; reverted in #12249.
|
||||
|
||||
---
|
||||
|
||||
@@ -613,18 +594,6 @@ inside your feature branch (a base-red fix is its own freeze-gated `fix/release-
|
||||
PR); and if you must open a PR anyway, add `⚠️ base-red inherited: #<issue>` to the PR body so
|
||||
reviewers and CI babysitters do not chase ghosts.
|
||||
|
||||
### Sync-back landings are fast-forward, never squash
|
||||
|
||||
A `main → release/vX+1` sync-back (Phase 5 of `/generate-release`, or any later "bring main's
|
||||
post-release commits over" PR) must reach the release branch as the merge commit it already is:
|
||||
`git merge-base --is-ancestor origin/release/vX+1 <head>` then
|
||||
`git push origin <head>:refs/heads/release/vX+1` (GitHub marks the PR merged). Squash-merging it
|
||||
drops `main` from the release branch's ancestry and the next sync-back re-conflicts on every file
|
||||
main touched (551 conflicts on the v3.8.50 → v3.8.51 sync before the two-step merge). After
|
||||
landing, `git merge-base --is-ancestor origin/main origin/release/vX+1` must be true — and check
|
||||
that `config/quality/eslint-suppressions.json` / `quality-baseline.json` carried main's freezes
|
||||
(they merge as "ours" silently). Details: `.agents/skills/generate-release/phases/phase-5-next-cycle.md`.
|
||||
|
||||
---
|
||||
|
||||
## Upstream contributions
|
||||
@@ -646,8 +615,8 @@ focused checks, and use a Conventional Commit message (for example, `docs: slim
|
||||
|
||||
## Environment
|
||||
|
||||
- **Runtime**: Node.js ≥22.22.2 <23 || ≥24.0.0 <27, ES Modules. This is the **only supported** 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. A **best-effort `bun:sqlite` compatibility path** exists so a global Bun install (`bun install -g omniroute`) can start without `better-sqlite3` (driver adapter + Bun-aware process spawning); it is **not** a supported runtime — no support guarantees — and every Bun-specific runtime change MUST preserve the Node driver/fallback chain and ship a Bun test (`test:bun:db`) or an explicit reason why the path is Node-only.
|
||||
- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.4.0` 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` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator 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 scripts with `bun: not found`).
|
||||
- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules. This is the **only supported** 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. A **best-effort `bun:sqlite` compatibility path** exists so a global Bun install (`bun install -g omniroute`) can start without `better-sqlite3` (driver adapter + Bun-aware process spawning); it is **not** a supported runtime — no support guarantees — and every Bun-specific runtime change MUST preserve the Node driver/fallback chain and ship a Bun test (`test:bun:db`) or an explicit reason why the path is Node-only.
|
||||
- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.3.14` 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` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator 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 scripts with `bun: not found`).
|
||||
- **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)
|
||||
@@ -659,12 +628,12 @@ focused checks, and use a Conventional Commit message (for example, `docs: slim
|
||||
|
||||
## Quality Gates & Ratchets
|
||||
|
||||
OmniRoute has **~90 quality-gate scripts** (`scripts/check/` + `scripts/quality/`) wired
|
||||
OmniRoute has **~80 quality-gate scripts** (`scripts/check/` + `scripts/quality/`) wired
|
||||
across **9 gate-running jobs** in `.github/workflows/ci.yml` (`lint`, `quality-gate`,
|
||||
`quality-extended`, `docs-sync-strict`, `i18n-ui-coverage`, `i18n`, `pr-test-policy`,
|
||||
`test-vitest`, `sonarqube`), plus the `quality.yml` fast-gates job (PR→`release/**`) and
|
||||
5 quality nightly workflows (`nightly-property`, `nightly-resilience`,
|
||||
`nightly-llm-security`, `nightly-mutation`, `nightly-schemathesis`). Full inventory, per-job breakdown, and operational
|
||||
3 nightly workflows (`nightly-property`, `nightly-resilience`, `nightly-llm-security`;
|
||||
`nightly-mutation` once merged). Full inventory, per-job breakdown, and operational
|
||||
procedures are in [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md).
|
||||
|
||||
**Quick reference:**
|
||||
@@ -676,10 +645,6 @@ procedures are in [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALI
|
||||
`npm run quality:ratchet -- --update` when a metric genuinely improves.
|
||||
- Job `test-vitest` runs `npm run test:vitest` (MCP tools, autoCombo, cache) — blocking.
|
||||
`test:vitest:ui` has been blocking since PR #7127.
|
||||
- **Velocity phase (2026-08-30 → v4.0)**: every numeric baseline is loosened by 20% and
|
||||
`--require-tighten` is advisory (`quality-baseline.json` → `_policy`); the nightly
|
||||
`baseline-headroom` job tracks how much of the budget is left in the issue
|
||||
"📈 Baseline headroom". See `docs/architecture/QUALITY_GATES.md` → "Velocity phase".
|
||||
|
||||
**Allowlist policy (short form):** Fix the cause; use the allowlist only for pre-existing
|
||||
violations you cannot fix in the same PR. Add a comment with justification + issue number.
|
||||
@@ -691,7 +656,7 @@ the stale-enforcement added in Fase 6A.3.
|
||||
## Hard Rules
|
||||
|
||||
1. Never commit secrets or credentials
|
||||
2. Never barrel-import from `localDb.ts` — import specific `src/lib/db/*` modules
|
||||
2. Never add logic to `localDb.ts`
|
||||
3. Never use `eval()` / `new Function()` / implied eval
|
||||
4. Never commit directly to `main`
|
||||
5. Never write raw SQL in routes — use `src/lib/db/` modules
|
||||
@@ -753,13 +718,3 @@ The dashboard is reachable at the operator's chosen URL/port (default `http://lo
|
||||
- **Local VPS / shared dev environments**: ask the operator for the URL and current credentials — they live in their personal vault, NOT in this repo.
|
||||
|
||||
> Any credential observed in a previous version of this file was a non-production demo value; treat it as compromised and do not reuse it.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
842
CHANGELOG.md
842
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -73,9 +73,6 @@ npm run dev
|
||||
npm run build # next build → .build/next/ then assembleStandalone → dist/
|
||||
npm run start
|
||||
|
||||
# Fast backend/API-only compile for contributor changes
|
||||
npm run build:contributor
|
||||
|
||||
# Release build (clean rebuild + HEAD sentinel — required for deploy)
|
||||
npm run build:release # rm -rf .build dist && build + writes dist/BUILD_SHA
|
||||
|
||||
@@ -83,10 +80,6 @@ npm run build:release # rm -rf .build dist && build + writes dist/BUILD_SHA
|
||||
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
||||
```
|
||||
|
||||
The contributor build performs compile-only validation: it does not assemble the standalone
|
||||
distribution or build optional native packaging assets. Use the regular production build when
|
||||
you need to validate the shippable bundle.
|
||||
|
||||
### Build Output Layout
|
||||
|
||||
| Directory | Contents | Tracked |
|
||||
@@ -107,11 +100,6 @@ npm run build
|
||||
`npm run build:release` additionally cleans both directories first and writes
|
||||
`dist/BUILD_SHA` (= `git rev-parse --short HEAD`) as a deploy integrity sentinel.
|
||||
|
||||
`npm run build:contributor` uses the backend-only build profile. It temporarily stubs
|
||||
dashboard UI files while building, keeps API route handlers, and restores the original files
|
||||
after the build. Use `npm run build` for changes that affect the dashboard UI or for full
|
||||
release validation; the contributor profile is not a replacement for the release build.
|
||||
|
||||
> **VPS deploy note:** the remote image directory `/usr/lib/node_modules/omniroute/app/`
|
||||
> is unchanged. The deploy skills rsync the contents of `dist/` into it.
|
||||
> Only the in-repo build output path moved (`app/` → `dist/`).
|
||||
@@ -177,13 +165,6 @@ npm run test:all
|
||||
# Single test file (Node.js native test runner — most tests use this)
|
||||
node --import tsx/esm --test tests/unit/your-file.test.ts
|
||||
|
||||
# Only the unit tests impacted by your change (same TIA selector as the CI gate, #8084)
|
||||
npm run test:scoped # changes in the last commit (or the working tree)
|
||||
npm run test:scoped:staged # staged changes only — pairs well with a pre-commit run
|
||||
npm run test:scoped:full # rebuild the import-graph map first (after adding/moving files)
|
||||
# Exit 1 + "run the full suite" means a hub file (tsconfig, package.json, …) or an
|
||||
# unmapped source changed — the selector fails safe, it never silently skips.
|
||||
|
||||
# Vitest (MCP server, autoCombo, cache)
|
||||
npm run test:vitest
|
||||
|
||||
@@ -320,7 +301,7 @@ src/ # TypeScript (.ts / .tsx)
|
||||
open-sse/ # @omniroute/open-sse workspace
|
||||
├── executors/ # 89 executor implementation modules
|
||||
├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.)
|
||||
├── mcp-server/ # MCP server (110 unique tools, 3 transports, 33 scopes)
|
||||
├── mcp-server/ # MCP server (107 unique tools, 3 transports, 32 scopes)
|
||||
├── services/ # 178 top-level services (combo, autoCombo, rateLimitManager, etc.)
|
||||
├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama)
|
||||
├── transformer/ # Responses API transformer
|
||||
|
||||
70
Dockerfile
70
Dockerfile
@@ -59,12 +59,6 @@ RUN set -eux; \
|
||||
# ── Builder ────────────────────────────────────────────────────────────────
|
||||
FROM base AS builder
|
||||
|
||||
# No telemetry, anywhere. Disable Next.js's anonymous build-time telemetry
|
||||
# (it otherwise pings Vercel during `next build`). Set on the builder stage so
|
||||
# every image build is silent; the runtime never builds, so this covers the
|
||||
# only phase Next telemetry can fire.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build tools for native module compilation
|
||||
# apt-get update needed here because base's rm -rf clears the shared cache
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
@@ -103,12 +97,25 @@ RUN test -f package-lock.json \
|
||||
# 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).
|
||||
#
|
||||
# tls-client-node (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web TLS
|
||||
# impersonation) hits the same --ignore-scripts wall: its own postinstall.js
|
||||
# fetches a platform .so/.dylib/.dll from the bogdanfinn/tls-client GitHub
|
||||
# Releases API and is never invoked when npm ci skips lifecycle scripts. Unlike
|
||||
# better-sqlite3 above, that script never throws on failure — it only
|
||||
# `console.warn`s and exits 0 — so a rate-limited or offline build would
|
||||
# otherwise succeed silently with an empty bin/ and only fail at first request
|
||||
# in production (TlsClientUnavailableError, #7802). Run it explicitly here so
|
||||
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
|
||||
# broken image.
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
|
||||
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& (cd node_modules/better-sqlite3 \
|
||||
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()" \
|
||||
&& node -e "const wreq=require('wreq-js'); if(typeof wreq.createTransport!=='function') process.exit(1)"
|
||||
&& node node_modules/tls-client-node/scripts/postinstall.js \
|
||||
&& (test -n "$(find node_modules/tls-client-node/bin -mindepth 1 -print -quit 2>/dev/null)" \
|
||||
|| (echo "tls-client-node native binary missing after postinstall — GitHub API fetch likely rate-limited or failed (#7802)" >&2 && exit 1))
|
||||
|
||||
# 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
|
||||
@@ -159,43 +166,9 @@ ENV OMNIROUTE_MITM_STUB=1
|
||||
# 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`.
|
||||
# Default raised 4096 → 6144 (#10060): the Next 16 production pass on a codebase
|
||||
# this size intermittently OOMs a build worker at 4 GB on memory-tight hosts.
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=6144
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
|
||||
# Cap Next.js build worker pools. Next 16 defaults to `os.cpus().length - 1`
|
||||
# workers for page-data collection (31 on a 32-core builder); on memory-tight
|
||||
# hosts 31 workers + webpack's multi-GB heap blow past RAM and a worker dies
|
||||
# with SIGSEGV at teardown ("worker exited with code: null and signal: SIGSEGV"),
|
||||
# silently leaving no standalone bundle. Next derives the worker count from
|
||||
# CIRCLE_NODE_TOTAL (workers = N-1). (#10060)
|
||||
#
|
||||
# Lowered 8 → 3 (7 workers → 2) in #11419, then 3 → 2 (2 workers → 1) in #7518.
|
||||
# Every page-data worker inherits NODE_OPTIONS above, so the ceiling is per
|
||||
# PROCESS, not per build: 7 workers on a 16 GB GitHub runner (ubuntu-24.04 /
|
||||
# ubuntu-24.04-arm, 4 vCPU) exhausted the host and buildkit failed the whole
|
||||
# step with `ResourceExhausted: ... cannot allocate memory`. The compile phase
|
||||
# always finished ("✓ Compiled successfully in 4.2min"); the kernel killed the
|
||||
# build right after "Collecting page data using N workers".
|
||||
#
|
||||
# #11419's first fix (8 → 3) modeled the per-worker peak as an INFERENCE
|
||||
# (2560 MB, guessed from "7 workers didn't fit") and assumed the parent
|
||||
# process's RSS tracked the V8 heap ceiling. Both assumptions were wrong: a
|
||||
# live VPS reproduction (issue #7518, dmesg OOM-killer report) measured the
|
||||
# real per-process RSS directly at ~4.5 GB, independent of the NODE_OPTIONS
|
||||
# heap flag (Turbopack itself is native/Rust, outside the V8 heap) — and it
|
||||
# applies to the parent process too, not just workers. 2 workers (3 processes
|
||||
# × 4.5 GB = 13.5 GB) still didn't fit the 12.288 GB (75%) budget on a 16 GB
|
||||
# runner, matching the still-live publish failures after #11419 merged. 1
|
||||
# worker (2 processes × 4.5 GB = 9 GB) fits with headroom to spare.
|
||||
# tests/unit/docker-build-memory-budget.test.ts does the arithmetic against
|
||||
# the measured figure and fails if either knob is raised past what a 16 GB
|
||||
# runner holds. Override for a big builder: `--build-arg
|
||||
# OMNIROUTE_BUILD_WORKERS=8`.
|
||||
ARG OMNIROUTE_BUILD_WORKERS=2
|
||||
ENV CIRCLE_NODE_TOTAL=${OMNIROUTE_BUILD_WORKERS}
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data \
|
||||
@@ -331,18 +304,7 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe
|
||||
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
|
||||
# Install CLI tools globally. Separate layer from apt for better cache reuse.
|
||||
# Pinned to exact versions per Diego's diagnosis in #12576 — floating
|
||||
# `@latest` causes two CI failures:
|
||||
# 1. `openclaw` ships a breaking major ~weekly; overnight builds silently
|
||||
# advance to a version that no longer matches the tested combo stack.
|
||||
# 2. `codex` / `claude-code` dev pre-releases (`@next`, dist-tags) mutate
|
||||
# API surface without notice; reproducible builds need a SHA-pinned dev
|
||||
# build, not the floating `@latest`.
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
|
||||
npm install -g --no-audit --no-fund \
|
||||
@openai/codex@0.153.2 \
|
||||
@anthropic-ai/claude-code@2.1.260 \
|
||||
droid@0.212.0 \
|
||||
openclaw@2026.9.1
|
||||
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
|
||||
|
||||
USER node
|
||||
|
||||
166
Dockerfile.bun
166
Dockerfile.bun
@@ -1,166 +0,0 @@
|
||||
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
|
||||
FROM oven/bun:1.4.0-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
python3 \
|
||||
python-is-python3 \
|
||||
make \
|
||||
g++ \
|
||||
libsecret-1-0 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Builder stage (100% Bun Native Install & Build) ─────────────────────────
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Cache dependency layer
|
||||
COPY package.json bun.lock* pnpm-workspace.yaml* ./
|
||||
COPY open-sse/package.json ./open-sse/package.json
|
||||
COPY packages/ ./packages/
|
||||
|
||||
# Root postinstall helpers needed during bun install lifecycle
|
||||
COPY scripts/build/ ./scripts/build/
|
||||
COPY scripts/dev/sync-env.mjs ./scripts/dev/sync-env.mjs
|
||||
|
||||
# Fast Bun native package install
|
||||
RUN bun install --include=optional --quiet
|
||||
|
||||
# Fail the build if wreq-js cannot resolve its current platform binding.
|
||||
RUN bun -e "const wreq = require('wreq-js'); if (typeof wreq.createTransport !== 'function') process.exit(1)"
|
||||
|
||||
# Smoke check native database driver used by Bun (bun:sqlite)
|
||||
RUN bun -e "import { Database } from 'bun:sqlite'; const db = new Database(':memory:'); db.query('SELECT 1 AS ok').get(); db.close(); console.log('bun:sqlite smoke: OK');"
|
||||
|
||||
COPY . .
|
||||
|
||||
# Turbopack is supported on Bun 1.4 + Next 16.3; override via --build-arg OMNIROUTE_USE_TURBOPACK=0 if needed
|
||||
ARG OMNIROUTE_USE_TURBOPACK=1
|
||||
ENV OMNIROUTE_USE_TURBOPACK=${OMNIROUTE_USE_TURBOPACK}
|
||||
|
||||
ARG OMNIROUTE_BASE_PATH=""
|
||||
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
|
||||
|
||||
ARG DASHBOARD_ALLOW_EMBED=""
|
||||
ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Bun native Next.js build execution
|
||||
RUN bun run --quiet build
|
||||
|
||||
# ── Runner Base stage (100% Bun Native Production Runtime) ──────────────────
|
||||
FROM oven/bun:1.4.0-slim AS runner-base
|
||||
|
||||
LABEL org.opencontainers.image.title="omniroute" \
|
||||
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \
|
||||
org.opencontainers.image.url="https://omniroute.online" \
|
||||
org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libsecret-1-0 \
|
||||
ca-certificates \
|
||||
curl \
|
||||
sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=20128
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV OMNIROUTE_MEMORY_MB=1024
|
||||
|
||||
ENV DATA_DIR=/app/data
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
COPY --from=builder /app/.build/next/standalone ./
|
||||
|
||||
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
|
||||
|
||||
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
|
||||
|
||||
# Bun uses bun:sqlite. Remove every standalone/vendor copy of the Node-only
|
||||
# addon so no traced chunk can dlopen it and abort the process before fallback.
|
||||
RUN find /app \
|
||||
-path '*/node_modules/better-sqlite3' \
|
||||
-prune \
|
||||
-exec rm -rf '{}' + \
|
||||
&& test -z "$(find /app -type f -name 'better_sqlite3.node' -print -quit)"
|
||||
|
||||
RUN chown -R bun:bun /app /app/data
|
||||
|
||||
USER bun
|
||||
|
||||
EXPOSE 20128
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD bun healthcheck.mjs || exit 1
|
||||
|
||||
ENTRYPOINT ["bun", "dev/run-standalone.mjs"]
|
||||
|
||||
# ── Runner Web stage (Bun Native + Chromium/Playwright for Web providers) ───
|
||||
FROM runner-base AS runner-web
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
chromium \
|
||||
chromium-driver \
|
||||
fonts-liberation \
|
||||
libasound2t64 \
|
||||
gconf-service \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libc6 \
|
||||
libcairo2 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libexpat1 \
|
||||
libfontconfig1 \
|
||||
libgbm1 \
|
||||
libgcc-s1 \
|
||||
libglib2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libpango-1.0-0 \
|
||||
pangocairo-1.0-0 \
|
||||
stdc++6 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxcomposite1 \
|
||||
libxcursor1 \
|
||||
libxdamage1 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
libxi6 \
|
||||
libxrandr2 \
|
||||
libxrender1 \
|
||||
libxss1 \
|
||||
libxtst6 \
|
||||
ca-certificates \
|
||||
fonts-gargi \
|
||||
fonts-ipafont-gothic \
|
||||
fonts-kacst \
|
||||
fonts-thai-tlwg \
|
||||
fonts-wqy-zenhei \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
# Drop back to default non-root user
|
||||
USER bun
|
||||
|
||||
ENTRYPOINT ["bun", "dev/run-standalone.mjs"]
|
||||
521
README.md
521
README.md
@@ -7,19 +7,19 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.47B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 348 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 348 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 💰 ~1.47B Free Tokens / Month
|
||||
## 💰 ~1.51B Free Tokens / Month
|
||||
|
||||
</div>
|
||||
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **444 free-tier entries across 34 recurring pool keys** and computes the token headline from the **16 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute aggregates the **documented** free tiers of **42 provider pools / 495 models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`).
|
||||
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.47B free tokens per month steady, up to ~2.10B in the first month with signup credits, from 34 documented recurring pool keys covering 444 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 16 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from the documented free tiers of 42 provider pools / 495 models behind one endpoint. Honest pool-deduped math — each shared pool counted once (counting every rate limit 24/7 would read ~10B; not published), 15 providers ToS-flagged so you decide. Budget bar of the countable free pools with per-model grid (Mistral Large 3 1B, GPT-4o mini 150M, Gemini 2.5 Flash 60M … Claude Sonnet 4.5 25K), one-time first-month signup credits (vertex 300M, agentrouter 200M, predibase 25M, together 25M, glm-cn 20M, doubao 15M, ai21 10M, longcat 10M, deepseek 5M, hyperbolic 5M, nscale 5M), plus permanently-free no-token-cap providers (SiliconFlow, Z.AI GLM-Flash, Kilo, OpenCode Zen, baidu …) and a $10 OpenRouter top-up unlocking +24M/mo — surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
|
||||
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
|
||||
>
|
||||
@@ -49,11 +49,11 @@
|
||||
|
||||
[](https://discord.gg/U47eFqAXCn)
|
||||
[](https://t.me/omnirouteOficial)
|
||||
[](https://chat.whatsapp.com/FvuCbrpZmQ6I85n2vW5QIC?s=cl&p=a&mlu=4)
|
||||
[](https://chat.whatsapp.com/KWgatljAjmbELQory59Oti?s=cl&p=a&mlu=4)
|
||||
[](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
|
||||
[](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)
|
||||
[](https://omniroute.online)
|
||||
|
||||
**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/U47eFqAXCn) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/FvuCbrpZmQ6I85n2vW5QIC?s=cl&p=a&mlu=4) / [🇧🇷 Brasil](https://chat.whatsapp.com/KWgatljAjmbELQory59Oti?s=cl&p=a&mlu=4) / [Portal](https://portal.sthub.com.br/communities/groups/st-hub/channels/Omniroute-World-8kRjmK)**
|
||||
**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/U47eFqAXCn) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 Brasil](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)**
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -61,14 +61,14 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
|
||||
| ------------------------- | :-----: | :-----------------------: | :---------: |
|
||||
| 🌐 Providers | 290 | **352** | more queued |
|
||||
| 🧠 Unique chat model IDs | 1185 | **1312** | — |
|
||||
| 🖼️ Modality Bridge | — | 🆕 vision + audio + video | — |
|
||||
| 📡 Radar free catalog | — | 🆕 opt-in | — |
|
||||
| ⚖️ Quota-aware scheduling | — | 🆕 Quota-Share | — |
|
||||
| 📊 Quota telemetry | — | 🆕 live | — |
|
||||
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
|
||||
| ------------------------- | :-----: | :---------: | :---------: |
|
||||
| 🌐 Providers | 290 | **342** | more queued |
|
||||
| 🧠 Documented models | 1185 | **1202** | — |
|
||||
| 🖼️ Modality Bridge | — | 🆕 vision | video |
|
||||
| 📡 Radar free catalog | — | 🆕 opt-in | — |
|
||||
| ⚖️ Quota-aware scheduling | — | — | 🔭 next |
|
||||
| 📊 Quota telemetry | — | — | 🔭 next |
|
||||
|
||||
**→ [Roadmap](ROADMAP.md) — riding the rail to `v3.9.0 LTS`**
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<tr>
|
||||
<td align="right"><b>⚙️ Features</b></td>
|
||||
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
|
||||
<td align="center"><a href="#-352-ai-providers--154-catalog-marked-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-348-ai-providers--90-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI & MCP</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -126,14 +126,14 @@
|
||||
<td align="right"><b>📦 Project</b></td>
|
||||
<td align="center"><a href="#%EF%B8%8F-tech-stack">🛠️ Tech Stack</a></td>
|
||||
<td align="center"><a href="#-documentation">📖 Docs</a></td>
|
||||
<td align="center"><a href="#-600-contributors">👥 Contributors</a></td>
|
||||
<td align="center"><a href="#-500-contributors">👥 Contributors</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<b>🌐 In 42 languages</b>
|
||||
<b>🌐 In 43 languages</b>
|
||||
<br/><br/>
|
||||
<a href="README.md"><img src="docs/assets/flags/us.svg" width="30" alt="English (en)" title="English (en)"></a>
|
||||
<a href="docs/i18n/pt-BR/README.md"><img src="docs/assets/flags/br.svg" width="30" alt="Português — Brasil (pt-BR)" title="Português — Brasil (pt-BR)"></a>
|
||||
@@ -164,6 +164,7 @@
|
||||
<a href="docs/i18n/id/README.md"><img src="docs/assets/flags/id.svg" width="30" alt="Bahasa Indonesia (id)" title="Bahasa Indonesia (id)"></a>
|
||||
<a href="docs/i18n/ms/README.md"><img src="docs/assets/flags/my.svg" width="30" alt="Bahasa Melayu (ms)" title="Bahasa Melayu (ms)"></a>
|
||||
<a href="docs/i18n/phi/README.md"><img src="docs/assets/flags/ph.svg" width="30" alt="Filipino (phi)" title="Filipino (phi)"></a>
|
||||
<a href="docs/i18n/in/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="हिन्दी (in)" title="हिन्दी (in)"></a>
|
||||
<a href="docs/i18n/hi/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="हिन्दी (hi)" title="हिन्दी (hi)"></a>
|
||||
<a href="docs/i18n/gu/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="ગુજરાતી (gu)" title="ગુજરાતી (gu)"></a>
|
||||
<a href="docs/i18n/mr/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="मराठी (mr)" title="मराठी (mr)"></a>
|
||||
@@ -188,7 +189,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/works-zero-config.svg" width="100%" alt="Works the second you install it — zero config. Three steps: 1. Install — npm i -g omniroute, server boots on localhost:20128. 2. Point your tool at http://localhost:20128/v1 — any OpenAI-compatible tool (Claude Code, Cursor, Cline). 3. It answers — call model auto for an instant reply, with no API key, no signup, no configuration. Keyless provider OpenCode Free is pre-wired into the auto combo, so a fresh install responds out of the box."/>
|
||||
<img src="./docs/diagrams/works-zero-config.svg" width="100%" alt="Works the second you install it — zero config. Three steps: 1. Install — npm i -g omniroute, server boots on localhost:20128. 2. Point your tool at http://localhost:20128/v1 — any OpenAI-compatible tool (Claude Code, Cursor, Cline). 3. It answers — call model auto for an instant reply, with no API key, no signup, no configuration. Keyless free providers OpenCode Free and Felo are pre-wired into the auto combo, so a fresh install responds out of the box."/>
|
||||
|
||||
```bash
|
||||
# Fresh install, zero credentials — `auto` already works:
|
||||
@@ -197,7 +198,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
-d '{"model":"auto","messages":[{"role":"user","content":"Hello!"}]}'
|
||||
```
|
||||
|
||||
<sub>Prefer a specific free backend? Call `oc/…` (OpenCode Free) directly. Then graduate to `auto` and let OmniRoute pick.</sub>
|
||||
<sub>Prefer a specific free backend? Call it directly, e.g. `oc/…` (OpenCode Free) or `felo/…` (Felo). Then graduate to `auto` and let OmniRoute pick.</sub>
|
||||
|
||||
<sub>📦 Copy-paste quickstart scripts for **Python, Node.js, PHP, and cURL** → [`examples/quickstart/`](examples/quickstart/)</sub>
|
||||
|
||||
@@ -209,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 52 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 348 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 348 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 57 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
@@ -224,7 +225,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="./docs/diagrams/tier-cascade.svg" width="100%" alt="OmniRoute request flow: your IDE or CLI (Claude Code, Cursor, Cline…) calls one local endpoint (http://localhost:20128/v1); the OmniRoute Smart Router (RTK + Caveman compression, 19 routing strategies, circuit breakers, TLS stealth, MCP, A2A, guardrails) can fall back across 4 provider tiers while an eligible healthy target remains — Tier 1 Subscription, Tier 2 API Key, Tier 3 Cheap and Tier 4 Free."/>
|
||||
<img src="./docs/diagrams/tier-cascade.svg" width="100%" alt="OmniRoute request flow: your IDE or CLI (Claude Code, Cursor, Cline…) calls one local endpoint (http://localhost:20128/v1); the OmniRoute Smart Router (RTK + Caveman compression, 19 routing strategies, circuit breakers, TLS stealth, MCP, A2A, guardrails) auto-falls back across 4 provider tiers — Tier 1 Subscription (Claude Code, Codex, Copilot), quota out? Tier 2 API Key (DeepSeek, Groq, xAI), budget hit? Tier 3 Cheap (GLM $0.5, MiniMax $0.2), budget hit? Tier 4 Free (Kiro, Qoder, Pollinations) — always on."/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -265,7 +266,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
<tr>
|
||||
<td align="center" width="150">
|
||||
<a href="https://cheaperinference.com/?utm_source=omniroute">
|
||||
<img src="./public/providers/cli-generic.svg" width="64" alt="Cheaper Inference"/>
|
||||
<img src="public/providers/cheaperinference.svg" width="64" alt="Cheaper Inference"/>
|
||||
</a>
|
||||
<br/><b>Cheaper Inference</b><br/><sub>cheaperinference.com</sub><br/><br/>
|
||||
<img src="https://img.shields.io/badge/Open_Source_Friend-31f889?style=flat-square&labelColor=04170d" alt="Open Source Friend"/>
|
||||
@@ -291,7 +292,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
<tr>
|
||||
<td align="center" width="120">
|
||||
<a href="https://agentrouter.org/register?aff=70LM">
|
||||
<img src="./public/providers/cli-generic.svg" width="32" alt="AgentRouter"/>
|
||||
<img src="public/providers/agentrouter.png" width="32" alt="AgentRouter"/>
|
||||
</a>
|
||||
<br/><sub><b>AgentRouter</b></sub><br/><sub>agentrouter.org</sub>
|
||||
</td>
|
||||
@@ -317,7 +318,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
<img src="./docs/diagrams/strategies-grid.svg" width="100%" alt="All 19 combo routing strategies animated — one tile per strategy: priority, fill-first, weighted, round-robin, p2c, least-used, random, strict-random, cost-optimized, headroom, reset-window, reset-aware, context-relay, context-optimized, cache-optimized, lkgp, auto, fusion, pipeline. See the table above for what each one does."/>
|
||||
|
||||
> A **combo** is a chain of models OmniRoute routes across **automatically**. If quota runs out, a provider fails, or costs spike, the combo can move to the next eligible healthy model. 🛡️
|
||||
> A **combo** is a chain of models OmniRoute routes across **automatically**. Quota runs out, a provider fails, or costs spike — the combo silently slides to the next model. **This is what makes OmniRoute unbreakable.** 🛡️
|
||||
|
||||
### ⚡ Zero-config — just use `auto`
|
||||
|
||||
@@ -331,8 +332,6 @@ No combo to create. Set your model to `auto` (or a variant) and OmniRoute builds
|
||||
<tr><td align="left" nowrap><code>auto/cheap</code></td><td align="left">💰 Cheapest per token first</td></tr>
|
||||
<tr><td align="left" nowrap><code>auto/offline</code></td><td align="left">🔋 Most quota / rate-limit headroom first</td></tr>
|
||||
<tr><td align="left" nowrap><code>auto/smart</code></td><td align="left">🔭 Quality-first + 10% exploration to discover better models</td></tr>
|
||||
<tr><td align="left" nowrap><code>auto/lkgp</code></td><td align="left">📌 Explicit last-known-good-provider stickiness</td></tr>
|
||||
<tr><td align="left" nowrap><code>auto/chaos</code></td><td align="left">🧪 Fault-injection weights for resilience testing (chaos engineering)</td></tr>
|
||||
</table>
|
||||
|
||||
##
|
||||
@@ -425,12 +424,12 @@ All **19** strategies — mix & match per combo step:
|
||||
<tr>
|
||||
<td align="center">16</td>
|
||||
<td nowrap><code>lkgp</code></td>
|
||||
<td>Last-Known-Good Path — pins to the last successful provider, then falls back to rules</td>
|
||||
<td>Last-Known-Good Path — sticky to the last successful target</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">17</td>
|
||||
<td nowrap><code>auto</code></td>
|
||||
<td>16-factor live scoring across every connection 🤖</td>
|
||||
<td>14-factor live scoring across every connection 🤖</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">18</td>
|
||||
@@ -444,13 +443,13 @@ All **19** strategies — mix & match per combo step:
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>The Auto-Combo engine scores every candidate on **16 factors** (health, quota, cost, latency, task fit, quality, session availability…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
<sub>The Auto-Combo engine scores every candidate on **14 factors** (health, quota, cost, latency, success rate, freshness…) — see [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md).</sub>
|
||||
|
||||
##
|
||||
|
||||
### 🧱 Resilience is built in (3 independent layers)
|
||||
|
||||
<img src="./docs/diagrams/resilience-layers.svg" width="100%" alt="OmniRoute resilience — 3 independent self-healing layers, the right layer for the right failure. Layer 1 provider circuit breaker (whole provider): trips only on 408/5xx, thresholds OAuth 8× / API-key 12× / local 2×, resets 60s/30s/15s into a HALF-OPEN probe, lazy recovery; while OPEN the combo reroutes to the next provider. Layer 2 connection cooldown (one key/account): base 5s OAuth / 3s API-key, exponential ×2 backoff with anti-thundering-herd guard, 429 honors Retry-After, success clears all error state; one cooling key is skipped while sibling keys keep serving. Layer 3 model lockout (one model): per-model 429, local 404 or mode denials lock just that model — never the whole connection. Terminal states (banned, expired, credits exhausted) are for the operator, not cooldowns."/>
|
||||
<img src="./docs/diagrams/resilience-layers.svg" width="100%" alt="OmniRoute resilience — 3 independent self-healing layers, the right layer for the right failure. Layer 1 provider circuit breaker (whole provider): trips only on 408/5xx, thresholds OAuth 10× / API-key 15× / local 2×, resets 60s/30s/15s into a HALF-OPEN probe, lazy recovery; while OPEN the combo reroutes to the next provider. Layer 2 connection cooldown (one key/account): base 5s OAuth / 3s API-key, exponential ×2 backoff with anti-thundering-herd guard, 429 honors Retry-After, success clears all error state; one cooling key is skipped while sibling keys keep serving. Layer 3 model lockout (one model): per-model 429, local 404 or mode denials lock just that model — never the whole connection. Terminal states (banned, expired, credits exhausted) are for the operator, not cooldowns."/>
|
||||
|
||||
<sub>📖 [Auto-Combo Engine](docs/routing/AUTO-COMBO.md) · [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)</sub>
|
||||
|
||||
@@ -462,7 +461,7 @@ All **19** strategies — mix & match per combo step:
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 356 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 348 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
@@ -518,9 +517,9 @@ Pix copia-e-cola:
|
||||
|
||||
## 📡 OmniRoute Radar
|
||||
|
||||
The main free-tier headline remains **~1.47B tokens/month** from the documented,
|
||||
The main free-tier headline remains **~1.53B tokens/month** from the documented,
|
||||
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
|
||||
month to **~2.10B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
month to **~2.15B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
free-model availability between OmniRoute releases; the community catalog and every existing free
|
||||
feature remain free.
|
||||
|
||||
@@ -549,7 +548,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
- **🗜️ Compression hardening** — default-on inflation guard, Caveman packs for DE / FR / JA + Chinese (wényán), RTK filters for Gradle & .NET. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||
- **💸 Honest flat-rate cost** — subscription / coding-plan providers read **$0** in cost analytics; budget, quota & routing keep estimating. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **⚖️ Quota-Share routing** — split a shared account's quota fairly across pooled keys, work-conserving so idle slices are lent out. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
||||
- **🤖 One-command CLI/agent setup** — 13 registered `setup-*` commands; `omniroute run` launches 7 CLIs (Claude Code, Codex, Aider, Goose, OpenCode, Qwen Code, Gemini CLI); `omniroute configure` supports 10 targets with an interactive provider+model picker and per-context favorites. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute run` launches 7 CLIs (Claude Code, Codex, Aider, Goose, OpenCode, Qwen Code, Gemini CLI) with zero config written; `omniroute configure` is an interactive provider+model picker with per-context favorites. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
- **🛰️ Remote mode** — drive a remote OmniRoute with scoped tokens (`connect` / `contexts` / `tokens`) + an `antigravity` OAuth helper for VPS installs. → [Remote Mode](docs/guides/REMOTE-MODE.md)
|
||||
- **🧭 Smarter auto-routing** — `auto/<category>:<tier>` combos, **Fusion** (model panel + judge), task-aware routing, per-request model / mode / USD-budget overrides. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
- **🗜️ Pluggable compression** — 12 composable engines + Compression Studios: LLMLingua-2, two-tier Ultra, omniglyph, per-step fidelity gate, GCF v3.2, drag-reorder editor. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||
@@ -558,12 +557,11 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
- **🧠 Memory you control** — off by default, opt-in int8 vector quantization + typed decay, per-request `x-omniroute-no-memory`. → [Memory](docs/frameworks/MEMORY.md)
|
||||
- **🛡️ Security** — prompt-injection guard on every LLM route (red-team suite), opt-in credential-masking guardrail (redacts leaked API keys/secrets in both directions), free DuckDuckGo last-resort web search, and an optional OIDC login gate for the dashboard (password login always stays available). → [Guardrails](docs/security/GUARDRAILS.md)
|
||||
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Magnific, Adobe Firefly, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
- **🤝 More providers & agents** — cloud agents (Codex Cloud, Cursor, Devin, Jules), Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **352-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **348-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
- **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
|
||||
- **🧩 Also in the box** — plugin framework + marketplace, Omni/Agent/GitHub skills frameworks, Obsidian vault integration (22 MCP tools), OpenAI-compatible Batch & Files APIs, semantic response cache, gamification with leaderboards, ACP agent discovery (15 built-in agents), scheduled log export to BigQuery, `auto/chaos` fault injection, a Telegram bot bridge, an in-app version manager and LMArena-ELO free-provider rankings. → [Docs](docs/README.md)
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -579,8 +577,8 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
<td align="center" width="76"><a href="https://github.com/anthropics/claude-code"><img src="./public/providers/claude.svg" width="40" alt="Claude Code"/><br/><sub><b>Claude Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/openai/codex"><img src="./public/providers/codex.svg" width="40" alt="Codex CLI"/><br/><sub><b>Codex CLI</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/cline.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/cline.svg" width="40" alt="Cline"/></picture><br/><sub><b>Cline</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><a href="https://github.com/Kilo-Org/kilocode"><img src="./public/providers/cli-generic.svg" width="40" alt="Kilo Code"/><br/><sub><b>Kilo Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/Zoo-Code-Org/Zoo-Code"><img src="./public/providers/cli-generic.svg" width="40" alt="Zoo Code"/><br/><sub><b>Zoo Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/Kilo-Org/kilocode"><img src="./public/providers/kilocode.svg" width="40" alt="Kilo Code"/><br/><sub><b>Kilo Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/Zoo-Code-Org/Zoo-Code"><img src="./public/providers/zoocode.png" width="40" alt="Zoo Code"/><br/><sub><b>Zoo Code</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><img src="./public/providers/continue.svg" width="40" alt="Continue"/><br/><sub><b>Continue</b></sub><br/><sub> </sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -589,10 +587,10 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="jcode"/><br/><sub><b>jcode</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/deepseek.svg" width="40" alt="DeepSeek TUI"/><br/><sub><b>DeepSeek TUI</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="CodeWhale"/><br/><sub><b>CodeWhale</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><a href="https://github.com/anomalyco/opencode"><img src="./public/providers/cli-generic.svg" width="40" alt="OpenCode"/><br/><sub><b>OpenCode</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><a href="https://github.com/anomalyco/opencode"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/opencode.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/opencode.svg" width="40" alt="OpenCode"/></picture><br/><sub><b>OpenCode</b></sub><br/><sub> </sub></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Factory Droid"/><br/><sub><b>Factory Droid</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/droid.svg" width="40" alt="Factory Droid"/><br/><sub><b>Factory Droid</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/copilot.svg" width="40" alt="GitHub Copilot CLI"/><br/><sub><b>Copilot CLI</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/cursor.svg" width="40" alt="Cursor CLI"/><br/><sub><b>Cursor CLI</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Smelt"/><br/><sub><b>Smelt</b></sub><br/><sub> </sub></td>
|
||||
@@ -614,7 +612,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
<b>+ also works with</b> · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
|
||||
</div>
|
||||
|
||||
<sub>📖 Per-tool setup for all 36 tools (26 CLI Code's + 10 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
|
||||
<sub>📖 Per-tool setup for all 34 tools (26 CLI Code's + 8 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -633,7 +631,7 @@ omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
|
||||
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "reply OK"
|
||||
|
||||
# Or pick provider+model interactively and write the tool's own config:
|
||||
omniroute configure codex # also: claude opencode qwen aider goose gemini cline continue kilo
|
||||
omniroute configure codex # also: claude opencode qwen aider goose cline continue kilo
|
||||
```
|
||||
|
||||
Every command honors the active remote context (`omniroute connect <host>`), `--dry-run`
|
||||
@@ -644,11 +642,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 🌐 352 AI Providers — 152 Catalog-Marked Free
|
||||
## 🌐 348 AI Providers — 90+ Free
|
||||
|
||||
</div>
|
||||
|
||||
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **444 per-model rows**, **34 recurring pools** and **52 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
|
||||
> The most complete catalog of any open-source router: **348 providers**, **90+ with a free tier**, **57 free forever**.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -681,7 +679,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>…and 330+ more — every icon resolves live from the dashboard's provider catalog. 📖 [Provider Reference](docs/reference/PROVIDER_REFERENCE.md)</sub>
|
||||
<sub>…and 220+ more — every icon resolves live from the dashboard's provider catalog. 📖 [Provider Reference](docs/reference/PROVIDER_REFERENCE.md)</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -689,8 +687,8 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="150"><img src="./public/providers/cli-generic.svg" width="42" alt="OpenCode Zen"/><br/><b>OpenCode Zen</b><br/><sub>DeepSeek V4, Nemotron 3<br/>No token cap</sub></td>
|
||||
<td align="center" width="150"><img src="./public/providers/cli-generic.svg" width="42" alt="Kilo Code"/><br/><b>Kilo Code</b><br/><sub>Auto-router, Tencent Hy3<br/>Free forever</sub></td>
|
||||
<td align="center" width="150"><img src="./public/providers/opencode.svg" width="42" alt="OpenCode Zen"/><br/><b>OpenCode Zen</b><br/><sub>DeepSeek V4, Nemotron 3<br/>No token cap</sub></td>
|
||||
<td align="center" width="150"><img src="./public/providers/kilocode.svg" width="42" alt="Kilo Code"/><br/><b>Kilo Code</b><br/><sub>Auto-router, Tencent Hy3<br/>Free forever</sub></td>
|
||||
<td align="center" width="150"><img src="./public/providers/requesty.svg" width="42" alt="Requesty"/><br/><b>Requesty</b><br/><sub>GPT-OSS 120B, Nemotron<br/>Free forever</sub></td>
|
||||
<td align="center" width="150"><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/siliconcloud-color.svg" width="42" alt="SiliconFlow"/><br/><b>SiliconFlow</b><br/><sub>DeepSeek V3.2 / R1<br/>Free tier</sub></td>
|
||||
<td align="center" width="150"><img src="./public/providers/zhipu.svg" width="42" alt="Z.AI GLM"/><br/><b>Z.AI GLM</b><br/><sub>GLM-4.7 / 4.5-Flash<br/>Free forever</sub></td>
|
||||
@@ -724,7 +722,6 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
<tr><td align="left" nowrap>📦 <b>npm (global)</b></td><td align="left" nowrap><code>npm install -g omniroute</code></td><td align="left">One command, any OS</td></tr>
|
||||
<tr><td align="left" nowrap>🐳 <b>Docker</b></td><td align="left" nowrap><code>docker run … diegosouzapw/omniroute</code></td><td align="left">Multi-arch <b>AMD64 + ARM64</b></td></tr>
|
||||
<tr><td align="left" nowrap>🖥️ <b>Desktop (Electron)</b></td><td align="left" nowrap><code>npm run electron:build</code></td><td align="left">Native window + system tray — <b>Windows / macOS / Linux</b></td></tr>
|
||||
<tr><td align="left" nowrap>🎩 <b>Menu-bar (OmniRouteTray)</b></td><td align="left" nowrap><code>brew install --cask zoispag/tap/omniroute-tray</code></td><td align="left">Supervises & auto-updates the server — <b>macOS</b></td></tr>
|
||||
<tr><td align="left" nowrap>💪 <b>ARM</b></td><td align="left" nowrap>native <code>arm64</code></td><td align="left">Raspberry Pi, ARM servers, Apple Silicon</td></tr>
|
||||
<tr><td align="left" nowrap>📱 <b>Android (Termux)</b></td><td align="left" nowrap><code>pkg install nodejs && npx -y omniroute</code></td><td align="left">Runs <b>on your phone</b>, 24/7, no root</td></tr>
|
||||
<tr><td align="left" nowrap>📲 <b>PWA</b></td><td align="left" nowrap>"Add to Home Screen"</td><td align="left">Fullscreen, offline, installable from browser</td></tr>
|
||||
@@ -733,7 +730,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
<tr><td align="left" nowrap>🛠️ <b>From source</b></td><td align="left" nowrap><code>npm install && npm run dev</code></td><td align="left">Hack on it, contribute</td></tr>
|
||||
</table>
|
||||
|
||||
<sub>📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Menu-bar tray](https://github.com/zoispag/omniroute-tray) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md)</sub>
|
||||
<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>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -768,47 +765,11 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl
|
||||
|
||||
<div align="center">
|
||||
|
||||
### 🎩 New: OmniRouteTray — your gateway, living in the menu bar
|
||||
|
||||
</div>
|
||||
|
||||
> `omniroute serve` is happiest when it's always on. **[OmniRouteTray](https://github.com/zoispag/omniroute-tray)**
|
||||
> turns that into a set-and-forget menu-bar app for macOS: it starts the server, keeps it alive
|
||||
> across reboots, updates it in place, and puts your live token budget one click away — **no
|
||||
> terminal window left open, no `npm install -g omniroute` to babysit.**
|
||||
|
||||
Built with [Tauri v2](https://v2.tauri.app/) (a Rust core the size of a rounding error), it ships
|
||||
its own signed Node 24 runtime and manages an app-owned OmniRoute install, so it never fights your
|
||||
global `node`/`bun`. It **shares your existing `~/.omniroute/` config and database** — so it's the
|
||||
same OmniRoute you already run, just with a hat on. 🎩
|
||||
|
||||
<table>
|
||||
<tr><th align="left">What it does</th><th align="left">How</th></tr>
|
||||
<tr><td align="left" nowrap>🟢 <b>Supervises the server</b></td><td align="left">Spawns <code>omniroute serve</code>, adopts an already-running instance instead of duplicating it</td></tr>
|
||||
<tr><td align="left" nowrap>📊 <b>Live usage at a glance</b></td><td align="left">Provider quota bars, Claude session/weekly limits with reset countdowns, 30-day cost breakdown</td></tr>
|
||||
<tr><td align="left" nowrap>🔄 <b>Auto-updates in place</b></td><td align="left">Staged install, atomic swap, rollback on failure — always on the newest release</td></tr>
|
||||
<tr><td align="left" nowrap>🚀 <b>Start on login</b></td><td align="left">Optional launch at login; tray-only, no dock icon</td></tr>
|
||||
<tr><td align="left" nowrap>🩺 <b>Doctor & logs</b></td><td align="left">One-click diagnostics and server log access</td></tr>
|
||||
</table>
|
||||
|
||||
```sh
|
||||
brew install --cask zoispag/tap/omniroute-tray
|
||||
```
|
||||
|
||||
<sub>Prefer a download? Grab the latest <code>.dmg</code> from
|
||||
<a href="https://github.com/zoispag/omniroute-tray/releases">Releases</a>. Source, issues and build
|
||||
docs live at <a href="https://github.com/zoispag/omniroute-tray">zoispag/omniroute-tray</a>.
|
||||
<br/>💛 A community project by <a href="https://github.com/zoispag">@zoispag</a> — not an official OmniRoute release.</sub>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 🔒 Private & Local-First
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/privacy-local.svg" width="100%" alt="Private and local-first — OmniRoute's gateway and control plane run on your machine. Prompts are sent to the upstream provider selected for each request; OmniRoute adds no hosted prompt-processing hop and telemetry is disabled by default. Credentials are encrypted at rest with AES-256-GCM; controls include API-key scoping, IP filtering, rate limits, prompt-injection guards, upstream-header scrubbing, opt-in PII redaction, sanitized errors and a local SQLite audit trail. OmniRoute is MIT-licensed and self-hostable."/>
|
||||
<img src="./docs/diagrams/privacy-local.svg" width="100%" alt="Private and local-first — your keys, your machine, your data; OmniRoute is a local proxy that never phones home. Eleven guarantees: runs 100% on your hardware (0 cloud hops), zero telemetry by default, credentials encrypted at rest (AES-256-GCM), no account or sign-up, hardened gateway (API-key scoping, IP filtering, rate limits, prompt-injection guard), loopback-only process routes, upstream header scrubbing, strictly opt-in PII redaction, sanitized errors that never leak internals, a local audit trail in your own SQLite, and MIT-licensed fully open-source code."/>
|
||||
|
||||
<sub>📖 [Authorization](docs/architecture/AUTHZ_GUIDE.md) · [Guardrails](docs/security/GUARDRAILS.md) · [Compliance](docs/security/COMPLIANCE.md)</sub>
|
||||
|
||||
@@ -849,7 +810,7 @@ Tokens are scoped `read` / `write` / `admin`; process-spawning routes stay loopb
|
||||
|
||||
<div align="left">
|
||||
|
||||
<img src="./docs/diagrams/cli-terminal.svg" width="50%" alt="Animated terminal demoing the OmniRoute CLI — omniroute providers list, omniroute combo list and omniroute health — cycling over the 86-command top-level surface: providers · oauth · keys · combo · nodes · models · cache · compression · cost · usage · quota · health · resilience · telemetry · logs · audit · mcp · a2a · cloud · memory · skills · eval · tunnel · backup · sync · webhooks · policy · pricing · translator · simulate …"/>
|
||||
<img src="./docs/diagrams/cli-terminal.svg" width="50%" alt="Animated terminal demoing the OmniRoute CLI — omniroute providers list, omniroute combo list, omniroute health — cycling over the 80+ command surface: providers · oauth · keys · combo · nodes · models · cache · compression · cost · usage · quota · health · resilience · telemetry · logs · audit · mcp · a2a · cloud · memory · skills · eval · tunnel · backup · sync · webhooks · policy · pricing · translator · simulate …"/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -860,11 +821,11 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo
|
||||
<table>
|
||||
<tr><th align="left">Interface</th><th align="left">Endpoint / command</th><th align="left">Use it for</th></tr>
|
||||
<tr><td align="left" nowrap>🧰 <b>MCP (stdio)</b></td><td align="left" nowrap><code>omniroute --mcp</code></td><td align="left">Plug into Claude Desktop, Cursor, any MCP client</td></tr>
|
||||
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>110 tools</b>, 33 scopes (enforcement opt-in), full audit trail</td></tr>
|
||||
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>110 tools</b>, 33 scopes, full audit trail</td></tr>
|
||||
<tr><td align="left" nowrap>📡 <b>MCP (SSE)</b></td><td align="left" nowrap><code>/api/mcp/sse</code></td><td align="left">Streaming MCP transport</td></tr>
|
||||
<tr><td align="left" nowrap>🤝 <b>A2A</b></td><td align="left" nowrap><code>/.well-known/agent.json</code></td><td align="left">Agent-to-agent, <b>JSON-RPC 2.0</b> + SSE, 6 skills</td></tr>
|
||||
<tr><td align="left" nowrap>🌐 <b>REST API</b></td><td align="left" nowrap><code>/v1/*</code></td><td align="left">OpenAI-compatible — chat, embeddings, images, audio, OCR</td></tr>
|
||||
<tr><td align="left" nowrap>🔔 <b>Webhooks</b></td><td align="left" nowrap><code>/api/webhooks</code></td><td align="left">Push request / quota events to Slack, Discord, Telegram or any URL</td></tr>
|
||||
<tr><td align="left" nowrap>🔔 <b>Webhooks</b></td><td align="left" nowrap><code>/api/webhooks</code></td><td align="left">Push events (usage, quota, errors, routing) to your URL</td></tr>
|
||||
<tr><td align="left" nowrap>🛰️ <b>Remote CLI</b></td><td align="left" nowrap><code>omniroute connect <host></code></td><td align="left">Drive a remote instance with scoped access tokens</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -885,7 +846,7 @@ claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp
|
||||
|
||||
### 📖 How it works — pipeline, architecture & savings math
|
||||
|
||||
<img src="./docs/diagrams/compression-pipeline.svg" width="100%" alt="OmniRoute compression pipeline: an illustrative 10,000-token client request passes through 12 composable engines — Session-Dedup, CCR, Lite, RTK, Responses Tool Output, Headroom, Relevance, Caveman, Aggressive, LLMLingua-2, Ultra and OmniGlyph — and can reach the provider at about 1,080 tokens in the documented stacked example. Structured content is protected by preservation guards and per-step fidelity gates; explicit lossy or experimental modes may transform eligible content."/>
|
||||
<img src="./docs/diagrams/compression-pipeline.svg" width="100%" alt="OmniRoute compression pipeline: a client request of 10,000 tokens passes through 12 stacked engines — Session-Dedup, CCR, Lite, RTK, Responses Tool Output, Headroom, Relevance, Caveman, Aggressive, LLMLingua-2, Ultra, OmniGlyph — and reaches the provider at about 1,080 tokens, up to 95% saved. Code, URLs and JSON are always preserved byte-perfect."/>
|
||||
|
||||
Default stacked combo runs `RTK → Caveman`. When both act on the same tool/context payload, savings compound:
|
||||
|
||||
@@ -956,8 +917,6 @@ The 12 engines above shrink what goes **in**. Three more layers shape **how**, *
|
||||
- **🪄 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.
|
||||
- **Ponytail (lazy senior dev)** — climb the YAGNI ladder, fix the root cause, smallest working diff.
|
||||
- **I have ADHD (action-first)** — next action leads, steps numbered, one concrete next step, no preamble.
|
||||
- **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.
|
||||
@@ -1031,11 +990,11 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
|
||||
|
||||
`:latest` follows the highest **published** stable SemVer. It does not track git `main`. Pin `:X.Y.Z` for GitOps. See [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).The image pins **`OMNIROUTE_MEMORY_MB=1024`**. That is enough for the dashboard and a light chat. **Coding agents** (`POST /v1/responses` from Claude Code, Codex, Grok, …) need a much larger V8 heap or the process `FATAL ERROR`s at ~12 GiB under two overlapping long contexts. Size the container above the heap (native buffers sit outside V8):
|
||||
|
||||
| Workload | Heap (`-e OMNIROUTE_MEMORY_MB`) | Container (`--memory`) |
|
||||
| ----------------------------------- | ------------------------------- | ---------------------- |
|
||||
| Dashboard / light chat | `1024` (image default) | ≥2 g |
|
||||
| One coding agent | `8192` | ≥10 g |
|
||||
| Two concurrent long `/v1/responses` | `10240`–`12288` | ≥12–16 g |
|
||||
| Workload | Heap (`-e OMNIROUTE_MEMORY_MB`) | Container (`--memory`) |
|
||||
| --- | --- | --- |
|
||||
| Dashboard / light chat | `1024` (image default) | ≥2 g |
|
||||
| One coding agent | `8192` | ≥10 g |
|
||||
| Two concurrent long `/v1/responses` | `10240`–`12288` | ≥12–16 g |
|
||||
|
||||
```bash
|
||||
docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
|
||||
@@ -1044,27 +1003,12 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
|
||||
```
|
||||
|
||||
Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-ram-for-coding-agents).
|
||||
|
||||
> **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and
|
||||
> `diegosouzapw/omniroute:next-web` follow the current default `release/v*`
|
||||
> branch. These mutable tags are intended only for testing unreleased fixes and
|
||||
> are **not supported for production**. See
|
||||
> [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).
|
||||
|
||||
**🥟 Bun**
|
||||
|
||||
Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection:
|
||||
|
||||
- **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`.
|
||||
- **Automatic Webpack bundler selection in dev**: Development (`bun run dev`) automatically detects Bun and disables Turbopack in favor of Webpack to prevent native V8 binding incompatibilities. Production builds (`bun run build`) follow `OMNIROUTE_USE_TURBOPACK` exactly as on Node: Turbopack by default, `OMNIROUTE_USE_TURBOPACK=0` to build with Webpack (`Dockerfile.bun` exposes it as a `--build-arg`).
|
||||
- **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`).
|
||||
|
||||
```bash
|
||||
# Install and run with Bun
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
**🛠️ From source**
|
||||
|
||||
```bash
|
||||
@@ -1147,7 +1091,7 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
|
||||
<div align="center">
|
||||
|
||||
<sub>Snapshot do painel em 2026-08-24 · Catálogo bruto: YT 809 | TT 137 | IG 124 · Frescor (dias): YT 1 | TT 21 | IG 22</sub>
|
||||
<sub>Dados de cobertura social em 2026-08-17 · YT: 741 | TT: 137 | IG: 124 · Frescor (dias): YT 0 · TT 14 · IG 15</sub>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -1156,52 +1100,52 @@ same process on one port, so there is no separate CLI-only package today.
|
||||
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+nick_saraev&font=montserrat&bold=true" alt="Instagram Reel" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #1 — Instagram</b><br/>
|
||||
<sub>nick_saraev — 3,042,474 views</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.instagram.com/reel/DaSs65mMrHk/">
|
||||
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+theopenstack&font=montserrat&bold=true" alt="Instagram Reel — theopenstack" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #2 — Instagram</b><br/>
|
||||
<sub>theopenstack — 692,419 views</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.tiktok.com/@milesreevesai/video/7667980059189366019">
|
||||
<img src="https://placehold.co/320x180/111827/FFFFFF?text=TikTok+%7C+milesreevesai&font=montserrat&bold=true" alt="TikTok — milesreevesai" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #3 — TikTok</b><br/>
|
||||
<sub>milesreevesai — 620,400 views</sub>
|
||||
<sub>nick_saraev — 1,628,910 views</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=QucgvbO5gsM">
|
||||
<img src="https://img.youtube.com/vi/QucgvbO5gsM/maxresdefault.jpg" alt="YouTube — Vaibhav Sisinty" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #4 — YouTube</b><br/>
|
||||
<sub>Vaibhav Sisinty — 391,109 views</sub>
|
||||
<b>🎬 #2 — YouTube</b><br/>
|
||||
<sub>Vaibhav Sisinty — 373,084 views</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.instagram.com/reel/DbIt9AjK7-U/">
|
||||
<img src="https://placehold.co/320x180/111827/FFFFFF?text=Instagram+Reel+%7C+buildwithai.club&font=montserrat&bold=true" alt="Instagram Reel — buildwithai.club" width="300"/>
|
||||
<a href="https://www.youtube.com/shorts/fZIBK_4fKq8">
|
||||
<img src="https://img.youtube.com/vi/fZIBK_4fKq8/maxresdefault.jpg" alt="YouTube Shorts" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #5 — Instagram</b><br/>
|
||||
<sub>buildwithai.club — 347,652 views</sub>
|
||||
<b>🎬 #3 — YouTube Shorts</b><br/>
|
||||
<sub>Nick Automates — 207,714 views</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.tiktok.com/@milesreevesai/video/7667980059189366019">
|
||||
<img src="https://placehold.co/320x180/111827/FFFFFF?text=TikTok+Top+1&font=montserrat&bold=true" alt="TikTok Thumbnail" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #4 — TikTok</b><br/>
|
||||
<sub>milesreevesai — 620,400 views</sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=LkP6ocAoQkk">
|
||||
<img src="https://img.youtube.com/vi/LkP6ocAoQkk/maxresdefault.jpg" alt="Valency Labs" width="300"/>
|
||||
</a><br/>
|
||||
<b>🎬 #5 — YouTube</b><br/>
|
||||
<sub>Valency Labs — 135,974 views</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
**Ranking completo (URLs canônicas deduplicadas, `v > 0`, maior alcance):**
|
||||
**Ranking completo (`v > 0`, maior alcance):**
|
||||
|
||||
| #1 | #2 | #3 | #4 | #5 |
|
||||
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| [nick_saraev — Instagram](https://www.instagram.com/reel/Da8ZthUPK98/) — **3,042,474** | [theopenstack — Instagram](https://www.instagram.com/reel/DaSs65mMrHk/) — **692,419** | [milesreevesai — TikTok](https://www.tiktok.com/@milesreevesai/video/7667980059189366019) — **620,400** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=QucgvbO5gsM) — **391,109** | [buildwithai.club — Instagram](https://www.instagram.com/reel/DbIt9AjK7-U/) — **347,652** |
|
||||
| #1 | #2 | #3 | #4 | #5 |
|
||||
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| [nick_saraev — Instagram](https://www.instagram.com/reel/Da8ZthUPK98/) — **1,628,910** | [milesreevesai — TikTok](https://www.tiktok.com/@milesreevesai/video/7667980059189366019) — **620,400** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=QucgvbO5gsM) — **373,084** | [Nick Automates — YouTube Shorts](https://www.youtube.com/shorts/fZIBK_4fKq8) — **207,714** | [midudev — TikTok](https://www.tiktok.com/@midudev/video/7664636453544152342) — **177,800** |
|
||||
|
||||
| #6 | #7 | #8 | #9 | #10 |
|
||||
| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| [nivedan.ai — Instagram](https://www.instagram.com/reel/DbIrCksJiqq/) — **331,973** | [vaibhavsisinty — Instagram](https://www.instagram.com/reel/Dae05TSAK1l/) — **263,744** | [Nick Automates — YouTube Shorts](https://www.youtube.com/shorts/fZIBK_4fKq8) — **218,174** | [theroshankrishna — Instagram](https://www.instagram.com/reel/Dapjs58z0P0/) — **186,786** | [midudev — TikTok](https://www.tiktok.com/@midudev/video/7664636453544152342) — **177,800** |
|
||||
| #6 | #7 | #8 | #9 | #10 |
|
||||
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| [theopenstack — Instagram](https://www.instagram.com/reel/DaSs65mMrHk/) — **155,453** | [t.ghoush.ai — TikTok](https://www.tiktok.com/@t.ghoush.ai/video/7669497680527248656) — **152,800** | [Valency Labs — YouTube](https://www.youtube.com/watch?v=LkP6ocAoQkk) — **135,974** | [Asati — YouTube](https://www.youtube.com/watch?v=JjPtJcqwhqg) — **126,130** | [Vaibhav Sisinty — YouTube](https://www.youtube.com/watch?v=NuNDpeZYQ28) — **122,672** |
|
||||
|
||||
Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 visualizações conhecidas** (`v > 0`) · **639 canais/perfis por rede**. O painel bruto contém 1.070 linhas; 41 duplicatas do Instagram foram normalizadas pela URL canônica, mantendo a maior contagem por vídeo.
|
||||
Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações conhecidas · 595 perfis/canais · 13+ idiomas · 13+ criadores.
|
||||
|
||||
> 🎬 **Made a video about OmniRoute?** Open an [issue](https://github.com/diegosouzapw/OmniRoute/issues/new) or [discussion](https://github.com/diegosouzapw/OmniRoute/discussions) with the link — we'll feature it here.
|
||||
|
||||
@@ -1219,10 +1163,9 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
| 🐙 **GitHub** — follow for releases & tips | [@diegosouzapw](https://github.com/diegosouzapw) |
|
||||
| 💬 **Discord** | [discord.gg/U47eFqAXCn](https://discord.gg/U47eFqAXCn) |
|
||||
| ✈️ **Telegram** | [t.me/omnirouteOficial](https://t.me/omnirouteOficial) |
|
||||
| 🟢 **WhatsApp — 🌍 Global** | [join the group](https://chat.whatsapp.com/FvuCbrpZmQ6I85n2vW5QIC?s=cl&p=a&mlu=4) |
|
||||
| 🟢 **WhatsApp — 🇧🇷 Brasil** | [entrar no grupo](https://chat.whatsapp.com/KWgatljAjmbELQory59Oti?s=cl&p=a&mlu=4) |
|
||||
| 🟢 **WhatsApp — 🌍 Global** | [join the group](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) |
|
||||
| 🟢 **WhatsApp — 🇧🇷 Brasil** | [entrar no grupo](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz) |
|
||||
| 🌍 **Website** | [omniroute.online](https://omniroute.online) |
|
||||
| 🌍 **🌍StHub OmniRoute Community (free)** | [portal sthub](https://portal.sthub.com.br/communities/groups/st-hub/channels/Omniroute-World-8kRjmK) |
|
||||
| 📦 **Source code** | [github.com/diegosouzapw/OmniRoute](https://github.com/diegosouzapw/OmniRoute) |
|
||||
| 🐛 **Report a bug** | [open an issue](https://github.com/diegosouzapw/OmniRoute/issues) — attach `npm run system-info` output |
|
||||
| 🤝 **Contribute** | [CONTRIBUTING.md](CONTRIBUTING.md) · [Branching & Release Model](docs/ops/BRANCHING_MODEL.md) · pick a `good first issue` |
|
||||
@@ -1244,7 +1187,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>>=22.22.2 <23 || >=24.0.0 <27</code></td></tr>
|
||||
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
|
||||
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 169 migrations</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 157 migrations</td></tr>
|
||||
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
|
||||
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
|
||||
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
|
||||
@@ -1254,7 +1197,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b>Stealth</b></td><td>wreq-js — JA3 / JA4 TLS fingerprint impersonation, 3-level proxy</td></tr>
|
||||
<tr><td nowrap><b>Resilience</b></td><td>Circuit breaker, exponential backoff, anti-thundering-herd, auto-combo self-healing</td></tr>
|
||||
<tr><td nowrap><b>Logging</b></td><td>pino — structured JSON logs with request context</td></tr>
|
||||
<tr><td nowrap><b>Testing</b></td><td>Node.js test runner + Vitest — <b>39,000+ static test declarations</b> across 5,100+ tracked test files (unit, integration, E2E, security, ecosystem)</td></tr>
|
||||
<tr><td nowrap><b>Testing</b></td><td>Node.js test runner + Vitest — <b>25,000+ test cases</b> across 3,300+ files (unit, integration, E2E, security, ecosystem)</td></tr>
|
||||
<tr><td nowrap><b>Platforms</b></td><td>Desktop (Electron) · Android (Termux) · PWA (any browser)</td></tr>
|
||||
<tr><td nowrap><b>CI/CD</b></td><td>GitHub Actions — auto npm publish + Docker Hub on release</td></tr>
|
||||
<tr><td nowrap><b>Links</b></td><td><a href="https://omniroute.online">Website</a> · <a href="https://www.npmjs.com/package/omniroute">npm</a> · <a href="https://hub.docker.com/r/diegosouzapw/omniroute">Docker Hub</a></td></tr>
|
||||
@@ -1305,9 +1248,9 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b><a href="docs/compression/COMPRESSION_RULES_FORMAT.md">Compression Rules Format</a></b></td><td>JSON rule-pack schemas for Caveman and RTK filters</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/compression/COMPRESSION_LANGUAGE_PACKS.md">Compression Language Packs</a></b></td><td>Language detection and Caveman rule-pack authoring</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>16-factor scoring, mode packs, self-healing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>14-factor scoring, mode packs, self-healing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 34 documented recurring pools / 444 cataloged free-tier entries</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>90+ free providers consolidated directory (42 documented token pools / 495 models)</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
|
||||
</table>
|
||||
@@ -1318,7 +1261,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><th align="left">Document</th><th align="left">Description</th></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/API_REFERENCE.md">API Reference</a></b></td><td>All endpoints with examples</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/openapi.yaml">OpenAPI Spec</a></b></td><td>OpenAPI 3.0 specification</td></tr>
|
||||
<tr><td nowrap><b><a href="open-sse/mcp-server/README.md">MCP Server</a></b></td><td>110 MCP tools, IDE configs, Python/TS/Go clients</td></tr>
|
||||
<tr><td nowrap><b><a href="open-sse/mcp-server/README.md">MCP Server</a></b></td><td>109 MCP tools, IDE configs, Python/TS/Go clients</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/frameworks/MCP-SERVER.md">MCP Server Guide</a></b></td><td>MCP installation, transports, and tool reference</td></tr>
|
||||
<tr><td nowrap><b><a href="src/lib/a2a/README.md">A2A Server</a></b></td><td>JSON-RPC 2.0 protocol, skills, streaming, task mgmt</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/frameworks/A2A-SERVER.md">A2A Server Guide</a></b></td><td>A2A agent card, tasks, skills, and streaming</td></tr>
|
||||
@@ -1332,9 +1275,9 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b><a href="docs/ops/BRANCHING_MODEL.md">Branching & Release Model</a></b></td><td>Where PRs target (<code>release/*</code>), what <code>main</code> and tags mean</td></tr>
|
||||
<tr><td nowrap><b><a href="CHANGELOG.md">Changelog</a></b></td><td>Full per-version release history</td></tr>
|
||||
<tr><td nowrap><b><a href="SECURITY.md">Security Policy</a></b></td><td>Vulnerability reporting and security practices</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>42-language support, translation workflow, RTL</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>43-language support, translation workflow, RTL</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/RELEASE_CHECKLIST.md">Release Checklist</a></b></td><td>Pre-release validation steps</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/COVERAGE_PLAN.md">Coverage Plan</a></b></td><td>Test coverage strategy for 39,000+ static test declarations across 5,100+ tracked test files</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/COVERAGE_PLAN.md">Coverage Plan</a></b></td><td>Test coverage strategy and 25,000+ test suite</td></tr>
|
||||
</table>
|
||||
|
||||
<br/>
|
||||
@@ -1345,123 +1288,93 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
|
||||
> OmniRoute is shaped by a passionate open-source community. These individuals have made exceptional contributions that directly impact the quality, stability, and reach of the project. **Thank you.**
|
||||
|
||||
### External contributors by merged pull requests
|
||||
|
||||
<table>
|
||||
<tr><th align="center">Rank</th><th align="left">Contributor</th><th align="center">Merged PRs</th><th align="right">~Changed lines</th></tr>
|
||||
<tr><td align="center">1</td><td align="left"><a href="https://github.com/backryun"><b>backryun</b></a></td><td align="center">190</td><td align="right">227,977</td></tr>
|
||||
<tr><td align="center">2</td><td align="left"><a href="https://github.com/oyi77"><b>oyi77</b></a></td><td align="center">180</td><td align="right">407,678</td></tr>
|
||||
<tr><td align="center">3</td><td align="left"><a href="https://github.com/rdself"><b>rdself</b></a></td><td align="center">145</td><td align="right">80,663</td></tr>
|
||||
<tr><td align="center">4</td><td align="left"><a href="https://github.com/JxnLexn"><b>JxnLexn</b></a></td><td align="center">128</td><td align="right">387,049</td></tr>
|
||||
<tr><td align="center">5</td><td align="left"><a href="https://github.com/KooshaPari"><b>KooshaPari</b></a></td><td align="center">101</td><td align="right">125,747</td></tr>
|
||||
<tr><td align="center">6</td><td align="left"><a href="https://github.com/herjarsa"><b>herjarsa</b></a></td><td align="center">88</td><td align="right">230,872</td></tr>
|
||||
<tr><td align="center">7</td><td align="left"><a href="https://github.com/RaviTharuma"><b>RaviTharuma</b></a></td><td align="center">79</td><td align="right">55,106</td></tr>
|
||||
<tr><td align="center">8</td><td align="left"><a href="https://github.com/maxmad64bis"><b>maxmad64bis</b></a></td><td align="center">69</td><td align="right">394,715</td></tr>
|
||||
<tr><td align="center">9</td><td align="left"><a href="https://github.com/artickc"><b>artickc</b></a></td><td align="center">59</td><td align="right">33,260</td></tr>
|
||||
<tr><td align="center">10</td><td align="left"><a href="https://github.com/HouMinXi"><b>HouMinXi</b></a></td><td align="center">51</td><td align="right">47,334</td></tr>
|
||||
<tr><td align="center">10</td><td align="left"><a href="https://github.com/chirag127"><b>chirag127</b></a></td><td align="center">51</td><td align="right">5,153</td></tr>
|
||||
<tr><td align="center">12</td><td align="left"><a href="https://github.com/xz-dev"><b>xz-dev</b></a></td><td align="center">50</td><td align="right">245,976</td></tr>
|
||||
<tr><td align="center">13</td><td align="left"><a href="https://github.com/hartmark"><b>hartmark</b></a></td><td align="center">47</td><td align="right">52,185</td></tr>
|
||||
<tr><td align="center">14</td><td align="left"><a href="https://github.com/rqzbeh"><b>rqzbeh</b></a></td><td align="center">39</td><td align="right">143,181</td></tr>
|
||||
<tr><td align="center">15</td><td align="left"><a href="https://github.com/dhaern"><b>dhaern</b></a></td><td align="center">34</td><td align="right">19,559</td></tr>
|
||||
<tr><td align="center">16</td><td align="left"><a href="https://github.com/Dingding-leo"><b>Dingding-leo</b></a></td><td align="center">33</td><td align="right">1,986</td></tr>
|
||||
<tr><td align="center">17</td><td align="left"><a href="https://github.com/NomenAK"><b>NomenAK</b></a></td><td align="center">32</td><td align="right">13,854</td></tr>
|
||||
<tr><td align="center">18</td><td align="left"><a href="https://github.com/MumuTW"><b>MumuTW</b></a></td><td align="center">30</td><td align="right">16,953</td></tr>
|
||||
<tr><td align="center">19</td><td align="left"><a href="https://github.com/benzntech"><b>benzntech</b></a></td><td align="center">29</td><td align="right">11,641</td></tr>
|
||||
<tr><td align="center">20</td><td align="left"><a href="https://github.com/pacocartones"><b>pacocartones</b></a></td><td align="center">24</td><td align="right">9,331</td></tr>
|
||||
<tr><td align="center">20</td><td align="left"><a href="https://github.com/Prudhvivuda"><b>Prudhvivuda</b></a></td><td align="center">24</td><td align="right">6,312</td></tr>
|
||||
</table>
|
||||
|
||||
<sub>Frozen at live <code>release/v3.8.50</code> tip <code>dafb4ae808</code>, with merges through 2026-08-24 05:26:03 UTC. The paginated GitHub GraphQL census contains 5,911 merged PRs: 2,707 by the repository owner, 179 by Dependabot, and <b>3,025 external PRs from 535 distinct contributors</b>. “Changed lines” is GitHub additions + deletions and includes generated files, lockfiles, catalogs, translations and documentation; it is churn, not authored LOC. Ties at the cutoff are retained.</sub>
|
||||
|
||||
### GitHub-attributed commits
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/backryun">
|
||||
<img src="https://github.com/backryun.png" width="40" style="border-radius:50%" alt="backryun"/><br/>
|
||||
<b>backryun</b>
|
||||
</a><br/>
|
||||
<sub>🥇 220 GitHub-attributed commits</sub>
|
||||
</td>
|
||||
<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="Paijo"/><br/>
|
||||
<b>Paijo</b>
|
||||
<img src="https://github.com/oyi77.png" width="40" style="border-radius:50%" alt="oyi77"/><br/>
|
||||
<b>oyi77</b>
|
||||
</a><br/>
|
||||
<sub>🥈 219 GitHub-attributed commits</sub>
|
||||
<sub>🥇 213 commits • +114K 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/rdself">
|
||||
<img src="https://github.com/rdself.png" width="40" style="border-radius:50%" alt="Randi"/><br/>
|
||||
<b>Randi</b>
|
||||
<img src="https://github.com/rdself.png" width="40" style="border-radius:50%" alt="R.D. & Randi"/><br/>
|
||||
<b>R.D. & Randi</b>
|
||||
</a><br/>
|
||||
<sub>🥉 108 GitHub-attributed commits</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/RaviTharuma">
|
||||
<img src="https://github.com/RaviTharuma.png" width="40" style="border-radius:50%" alt="Ravi Tharuma"/><br/>
|
||||
<b>Ravi Tharuma</b>
|
||||
</a><br/>
|
||||
<sub>🏅 81 GitHub-attributed commits</sub>
|
||||
<sub>🥈 108 commits • +38K 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/christopher-s">
|
||||
<img src="https://github.com/christopher-s.png" width="40" style="border-radius:50%" alt="Chris"/><br/>
|
||||
<b>Chris</b>
|
||||
<img src="https://github.com/christopher-s.png" width="40" style="border-radius:50%" alt="Chris Staley"/><br/>
|
||||
<b>Chris Staley</b>
|
||||
</a><br/>
|
||||
<sub>🏅 70 GitHub-attributed commits</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/hartmark">
|
||||
<img src="https://github.com/hartmark.png" width="40" style="border-radius:50%" alt="Markus Hartung"/><br/>
|
||||
<b>Markus Hartung</b>
|
||||
</a><br/>
|
||||
<sub>🏅 69 GitHub-attributed commits · tied #6</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/maxmad64bis">
|
||||
<img src="https://github.com/maxmad64bis.png" width="40" style="border-radius:50%" alt="Dizzle"/><br/>
|
||||
<b>Dizzle</b>
|
||||
</a><br/>
|
||||
<sub>🏅 69 GitHub-attributed commits · tied #6</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/JxnLexn">
|
||||
<img src="https://github.com/JxnLexn.png" width="40" style="border-radius:50%" alt="Jan Leon"/><br/>
|
||||
<b>Jan Leon</b>
|
||||
</a><br/>
|
||||
<sub>🏅 64 GitHub-attributed commits</sub>
|
||||
<sub>🥉 70 commits • +1.8K 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/zen0bit">
|
||||
<img src="https://github.com/zen0bit.png" width="40" style="border-radius:50%" alt="zenobit"/><br/>
|
||||
<b>zenobit</b>
|
||||
</a><br/>
|
||||
<sub>🏅 62 GitHub-attributed commits</sub>
|
||||
<sub>🏅 62 commits • +22K lines</sub><br/>
|
||||
<sub>CI/CD pipeline, i18n for 33 languages,<br/>Void Linux package, platform fixes</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/HouMinXi">
|
||||
<img src="https://github.com/HouMinXi.png" width="40" style="border-radius:50%" alt="Bob.Hou"/><br/>
|
||||
<b>Bob.Hou</b>
|
||||
<a href="https://github.com/JxnLexn">
|
||||
<img src="https://github.com/JxnLexn.png" width="40" style="border-radius:50%" alt="Jan Leon"/><br/>
|
||||
<b>Jan Leon</b>
|
||||
</a><br/>
|
||||
<sub>🏅 51 GitHub-attributed commits · tied #10</sub>
|
||||
<sub>🏅 58 commits • +22K lines</sub><br/>
|
||||
<sub>Reasoning-effort routing, proxy controls,<br/>quota visibility, Live Zone compression</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/backryun">
|
||||
<img src="https://github.com/backryun.png" width="40" style="border-radius:50%" alt="backryun"/><br/>
|
||||
<b>backryun</b>
|
||||
</a><br/>
|
||||
<sub>🏅 53 commits • +70K lines</sub><br/>
|
||||
<sub>Provider catalog curation — Perplexity, Kimi,<br/>Cerebras, Copilot, LMArena refreshes</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/xz-dev">
|
||||
<img src="https://github.com/xz-dev.png" width="40" style="border-radius:50%" alt="Xiangzhe"/><br/>
|
||||
<b>Xiangzhe</b>
|
||||
<a href="https://github.com/chirag127">
|
||||
<img src="https://github.com/chirag127.png" width="40" style="border-radius:50%" alt="Chirag Singhal"/><br/>
|
||||
<b>Chirag Singhal</b>
|
||||
</a><br/>
|
||||
<sub>🏅 51 GitHub-attributed commits · tied #10</sub>
|
||||
<sub>🏅 46 commits • +4.8K lines</sub><br/>
|
||||
<sub>Error sanitization, MITM prefill fix,<br/>fusion judge, breaker/429 correctness</sub>
|
||||
</td>
|
||||
<td align="center" width="160">
|
||||
<a href="https://github.com/kfiramar">
|
||||
<img src="https://github.com/kfiramar.png" width="40" style="border-radius:50%" alt="kfiramar"/><br/>
|
||||
<b>kfiramar</b>
|
||||
</a><br/>
|
||||
<sub>🏅 38 commits • +1.7K lines</sub><br/>
|
||||
<sub>Codex websocket + passthrough, auth/onboarding,<br/>Electron hardening, DB migrations</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="Benson K B"/><br/>
|
||||
<b>Benson K B</b>
|
||||
</a><br/>
|
||||
<sub>🏅 28 commits • +9.2K 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="Hernan J. Ardila"/><br/>
|
||||
<b>Hernan J. Ardila</b>
|
||||
</a><br/>
|
||||
<sub>🏅 25 commits • +174K lines</sub><br/>
|
||||
<sub>Zero-latency combos, vision-bridge auto-routing,<br/>catalog context-length, resilience 429 hints</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>Rechecked at 2026-08-24 06:14:31 UTC: GitHub-attributed commits reported by the repository Contributors API for the <code>release/v3.8.50</code> default branch. The API returned 525 identities (415 users, 2 bots, 108 anonymous); this table excludes the maintainer, bots and anonymous identities and retains competition ties. It is distinct from both the merged-PR ranking above and the 639-person Git-metadata census below.</sub>
|
||||
|
||||
> 🙏 These contributors' features, bug fixes, and infrastructure improvements are a **core part** of what makes OmniRoute reliable and feature-rich. Every pull request, every test case, and every i18n translation file matters. Open source is built by people like them.
|
||||
|
||||
</div>
|
||||
@@ -1478,48 +1391,25 @@ A heartfelt thank-you to the people who fund OmniRoute out of their own pocket
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="180">
|
||||
<a href="https://github.com/drewbitt">
|
||||
<img src="https://github.com/drewbitt.png?size=140" width="72" style="border-radius:50%" alt="Andrew"/><br/>
|
||||
<b>Andrew</b>
|
||||
</a><br/>
|
||||
<sub>💛 Active monthly sponsor</sub>
|
||||
</td>
|
||||
<td align="center" width="180">
|
||||
<a href="https://github.com/psylligent">
|
||||
<img src="https://github.com/psylligent.png?size=140" width="72" style="border-radius:50%" alt="Vlad I"/><br/>
|
||||
<b>Vlad I</b>
|
||||
</a><br/>
|
||||
<sub>💛 Active monthly sponsor</sub>
|
||||
</td>
|
||||
<td align="center" width="180">
|
||||
<a href="https://github.com/pacocartones">
|
||||
<img src="https://github.com/pacocartones.png?size=140" width="72" style="border-radius:50%" alt="Paco Cartones"/><br/>
|
||||
<b>Paco Cartones</b>
|
||||
</a><br/>
|
||||
<sub>💛 Active one-time sponsor</sub>
|
||||
</td>
|
||||
<td align="center" width="180">
|
||||
<a href="https://github.com/igormorais123">
|
||||
<img src="https://github.com/igormorais123.png?size=140" width="72" style="border-radius:50%" alt="Professor Igor Morais Vasconcelos"/><br/>
|
||||
<b>Prof. Igor Morais</b>
|
||||
</a><br/>
|
||||
<sub>💛 Past one-time supporter</sub>
|
||||
<sub>💛 Sponsor</sub>
|
||||
</td>
|
||||
<td align="center" width="180">
|
||||
<a href="https://github.com/longtao77">
|
||||
<img src="https://github.com/longtao77.png?size=140" width="72" style="border-radius:50%" alt="longtao"/><br/>
|
||||
<b>longtao</b>
|
||||
</a><br/>
|
||||
<sub>💛 Past one-time supporter</sub>
|
||||
<sub>💛 Sponsor</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<sub>… and others who prefer to stay private 💛</sub>
|
||||
|
||||
<sub>Public GitHub Sponsors revalidated on 2026-08-24. GitHub's <code>activeOnly</code> status determines the active labels above; previously disclosed public one-time supporters remain thanked, and private sponsors remain anonymous.</sub>
|
||||
|
||||
<b><a href="https://github.com/sponsors/diegosouzapw">💖 Become a sponsor →</a></b> — every dollar keeps OmniRoute free and independent.
|
||||
|
||||
</div>
|
||||
@@ -1528,13 +1418,11 @@ A heartfelt thank-you to the people who fund OmniRoute out of their own pocket
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 👥 600+ Contributors
|
||||
## 👥 320+ Contributors
|
||||
|
||||
</div>
|
||||
|
||||
[](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
|
||||
|
||||
<sub>Audited on 2026-08-24 at frozen base <code>ac02c5b42f</code> and rechecked at live <code>release/v3.8.50</code> tip <code>dafb4ae808</code>: <b>639 normalized human Git identities</b> — 407 appear as commit authors (including the maintainer) and 232 only in explicit <code>Co-authored-by</code> trailers. The census normalizes GitHub noreply handles, excludes 26 bot/agent/service/placeholder identities, and does not merge ordinary email addresses merely because their display names match.</sub>
|
||||
[](https://github.com/diegosouzapw/OmniRoute/graphs/contributors)
|
||||
|
||||
### How to Contribute
|
||||
|
||||
@@ -1551,8 +1439,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
```bash
|
||||
# Create a release — npm publish happens automatically
|
||||
VERSION=x.y.z
|
||||
gh release create "v${VERSION}" --title "v${VERSION}" --generate-notes
|
||||
gh release create v3.8.2 --title "v3.8.2" --generate-notes
|
||||
```
|
||||
|
||||
<br/>
|
||||
@@ -1594,108 +1481,88 @@ gh release create "v${VERSION}" --title "v${VERSION}" --generate-notes
|
||||
|
||||
OmniRoute stands on the shoulders of giants. It started as a fork of **[9router](https://github.com/decolua/9router)** and a TypeScript port of the Go project **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — and from there, every subsystem below was inspired by an open-source project that got there first. Each one shaped a concrete piece of OmniRoute. This is our thank-you to all of them. 🙏
|
||||
|
||||
> ⭐ star counts verified from GitHub's REST API on August 24, 2026 — go give these projects a star. Counts are an exact dated snapshot and will naturally change.
|
||||
> ⭐ star counts as of July 2026 — go give these projects a star.
|
||||
|
||||
### 🧬 Lineage & gateway
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/decolua/9router">9router</a></b></td><td align="center">26,161</td><td>The original project this fork is built on — extended here with multi-modal APIs and a full TypeScript rewrite.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/router-for-me/CLIProxyAPI">CLIProxyAPI</a></b></td><td align="center">48,497</td><td>The Go implementation that inspired this JavaScript / TypeScript port.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/BerriAI/litellm">LiteLLM</a></b></td><td align="center">57,100</td><td>The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/miuuyy/codex-chatgpt-web">codex-chatgpt-web</a></b></td><td align="center">1,410</td><td>MIT source adapted into the vendored ChatGPT Web → Codex Responses bridge, including browser-session, response-framing, usage and web-search adapters.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/Alishahryar1/free-claude-code">free-claude-code</a></b></td><td align="center">48,112</td><td>Patterns ported into stream recovery, no-thinking aliases, fallback web search, sliding-window limits, log redaction and hardened launcher flows.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/standardagents/composer-api">composer-api</a></b></td><td align="center">322</td><td>Cursor Composer tool-choice, output-constraint and tool-commit patterns adapted into the native Cursor executor.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ndycode/codex-multi-auth">codex-multi-auth</a></b></td><td align="center">457</td><td>Fresh-login and refresh-token rotation patterns ported into Codex OAuth reauthentication.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ex-machina-co/opencode-anthropic-auth">opencode-anthropic-auth</a></b></td><td align="center">510</td><td>Claude Code-compatible transform defaults and billing-header behavior generalized into OmniRoute's config-driven bridge.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/520mmxx/grok2api-merged">grok2api-merged</a></b></td><td align="center">2</td><td>Its Grok model mappings, fake-TypeError Statsig generator, request and device defaults, and NDJSON response processor were materially adapted into OmniRoute's Grok Web executor.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/TQZHR/grok2api">TQZHR/grok2api</a></b></td><td align="center">705</td><td>The principal transitive code source behind grok2api-merged; its model, header, payload, Statsig and processor implementations are preserved in the Grok Web lineage.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/chenyme/grok2api">chenyme/grok2api</a></b></td><td align="center">7,520</td><td>The underlying MIT source for Grok payload and device defaults, the Statsig generator, and the <code>result.response</code> processor carried through TQZHR and grok2api-merged.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/miuzhaii/grok2api-pro">grok2api-pro</a></b></td><td align="center">27</td><td>A transitive source credited by grok2api-merged for its proxy-pool layer; OmniRoute preserves that lineage notice but does not claim a proxy-pool port in its bounded Grok Web executor.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/CNFlyCat/GrokProxy">GrokProxy</a></b></td><td align="center">50</td><td>Its cookie-authenticated Grok proxy and <code>result.response.token</code> streaming pattern informed OmniRoute's Grok Web transport.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/lianying1716/GrokBridge">GrokBridge</a></b></td><td align="center">5</td><td>The original Grok Web implementation consulted its HTTP/browser upstream design; its direct HTTP path derives from GrokProxy, so no independent code port is claimed.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/imjustprism/grok-web-api">grok-web-api</a></b></td><td align="center">14</td><td>Its Rust <code>ChatOptions</code> and response-envelope schemas informed OmniRoute's TypeScript Grok request and streaming-response types.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/decolua/9router">9router</a></b></td><td align="center">22.7k</td><td>The original project this fork is built on — extended here with multi-modal APIs and a full TypeScript rewrite.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/router-for-me/CLIProxyAPI">CLIProxyAPI</a></b></td><td align="center">43.6k</td><td>The Go implementation that inspired this JavaScript / TypeScript port.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/BerriAI/litellm">LiteLLM</a></b></td><td align="center">54.0k</td><td>The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing.</td></tr>
|
||||
</table>
|
||||
|
||||
### 🗜️ Context & token compression — engines
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/JuliusBrussee/caveman">Caveman</a></b></td><td align="center">100,538</td><td>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.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/rtk-ai/rtk">RTK – Rust Token Killer</a></b></td><td align="center">77,185</td><td>High-performance command-output compression — inspired our RTK engine, JSON filter DSL, raw-output recovery and the stacked RTK → Caveman pipeline.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/headroomlabs-ai/headroom">headroom</a></b></td><td align="center">67,310</td><td>Reversible context-compression (SmartCrusher) — inspired our <code>headroom</code> engine and the <code>ccr</code> retrieve-marker pattern.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/microsoft/LLMLingua">LLMLingua</a></b></td><td align="center">6,598</td><td>Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open <code>llmlingua</code> engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/atjsh/llmlingua-2-js">llmlingua-2-js</a></b></td><td align="center">31</td><td>The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/leninejunior/troglodita">Troglodita</a></b></td><td align="center">40</td><td>PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/DietrichGebert/ponytail">ponytail</a></b></td><td align="center">108,957</td><td>The viral "lazy senior dev" YAGNI-coder skill — inspired our <b>less-code</b> Output Style: smallest-working-change steering that cuts _generated_ code (the output-axis sibling to Caveman's terse prose).</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ayghri/i-have-adhd">i-have-adhd</a></b></td><td align="center">23,526</td><td>Its action-first, ADHD-friendly response style was adapted into OmniRoute's concise output style across five languages.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/JuliusBrussee/caveman">Caveman</a></b></td><td align="center">90.8k</td><td>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.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/rtk-ai/rtk">RTK – Rust Token Killer</a></b></td><td align="center">71.8k</td><td>High-performance command-output compression — inspired our RTK engine, JSON filter DSL, raw-output recovery and the stacked RTK → Caveman pipeline.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/headroomlabs-ai/headroom">headroom</a></b></td><td align="center">60.1k</td><td>Reversible context-compression (SmartCrusher) — inspired our <code>headroom</code> engine and the <code>ccr</code> retrieve-marker pattern.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/microsoft/LLMLingua">LLMLingua</a></b></td><td align="center">6.5k</td><td>Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open <code>llmlingua</code> engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/atjsh/llmlingua-2-js">llmlingua-2-js</a></b></td><td align="center">30</td><td>The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/leninejunior/troglodita">Troglodita</a></b></td><td align="center">26</td><td>PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/DietrichGebert/ponytail">ponytail</a></b></td><td align="center">86.0k</td><td>The viral "lazy senior dev" YAGNI-coder skill — inspired our <b>less-code</b> Output Style: smallest-working-change steering that cuts _generated_ code (the output-axis sibling to Caveman's terse prose).</td></tr>
|
||||
</table>
|
||||
|
||||
### 🧩 Compact formats, token research & code-aware tooling
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/toon-format/toon">TOON</a></b></td><td align="center">25,233</td><td>Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf">GCF – Graph Compact Format</a></b></td><td align="center">41</td><td>Its compact graph format and generic-profile design informed OmniRoute's tabular compaction and Headroom codec format.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf-typescript">gcf-typescript</a></b></td><td align="center">4</td><td>The MIT TypeScript implementation directly vendored and extended as the Headroom generic-profile codec.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ooples/token-optimizer-mcp">token-optimizer-mcp</a></b></td><td align="center">494</td><td>Brotli/SQLite cache + per-session context-delta — inspired our <code>session-dedup</code> engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/Mibayy/token-savior">token-savior</a></b></td><td align="center">1,122</td><td>Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ppgranger/token-saver">token-saver</a></b></td><td align="center">138</td><td>Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/alexgreensh/token-optimizer">token-optimizer</a></b></td><td align="center">1,951</td><td>"Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/Shweta-Mishra-ai/tokenmizer">TokenMizer</a></b></td><td align="center">28</td><td>A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/toon-format/toon">TOON</a></b></td><td align="center">24.9k</td><td>Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf">GCF – Graph Compact Format</a></b></td><td align="center">22</td><td>First inspired our tabular compaction stage; now its zero-dependency, lossless generic-profile encoder is <b>vendored directly</b> as the Headroom codec (MIT, SPDX-marked), with later numeric-domain and count-mismatch correctness fixes.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ooples/token-optimizer-mcp">token-optimizer-mcp</a></b></td><td align="center">444</td><td>Brotli/SQLite cache + per-session context-delta — inspired our <code>session-dedup</code> engine.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/Mibayy/token-savior">token-savior</a></b></td><td align="center">1.1k</td><td>Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/ppgranger/token-saver">token-saver</a></b></td><td align="center">117</td><td>Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/alexgreensh/token-optimizer">token-optimizer</a></b></td><td align="center">1.7k</td><td>"Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/Shweta-Mishra-ai/tokenmizer">TokenMizer</a></b></td><td align="center">16</td><td>A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/jessefreitas/OmniCompress">OmniCompress</a></b></td><td align="center">3</td><td>Rust columnar-JSON + content-addressed retrieve + cross-message dedup — validated our <code>headroom</code>/<code>ccr</code>/<code>session-dedup</code> engine design and the cache-stable "compressed form is position-independent" invariant.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/atlassian-labs/mcp-compressor">mcp-compressor</a></b></td><td align="center">113</td><td>MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/pdavis68/RepoMapper">RepoMapper</a></b></td><td align="center">197</td><td>Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/atlassian-labs/mcp-compressor">mcp-compressor</a></b></td><td align="center">98</td><td>MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/pdavis68/RepoMapper">RepoMapper</a></b></td><td align="center">187</td><td>Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/mrsimpson/quiet-shell-mcp">quiet-shell-mcp</a></b></td><td align="center">4</td><td>Declarative shell-output reduction over MCP — validated our declarative bash-output compaction.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/dsherret/ts-morph">ts-morph</a></b></td><td align="center">6,162</td><td>TypeScript Compiler API toolkit — inspired our parser-based comment removal that preserves string, template and regex literals.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/dsherret/ts-morph">ts-morph</a></b></td><td align="center">6.1k</td><td>TypeScript Compiler API toolkit — inspired our parser-based comment removal that preserves string, template and regex literals.</td></tr>
|
||||
</table>
|
||||
|
||||
### 🧠 Memory & RAG
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/mem0ai/mem0">Mem0</a></b></td><td align="center">63,902</td><td>Universal memory layer — its proxy-as-write/read-boundary model shaped our memory architecture.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/letta-ai/letta">Letta (MemGPT)</a></b></td><td align="center">24,382</td><td>Stateful agents with tiered memory — inspired our Context Control & Recovery (CCR) tiered model.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/onestardao/WFGY">WFGY</a></b></td><td align="center">1,781</td><td>The ProblemMap taxonomy of 16 recurring RAG/LLM failure modes — the shared vocabulary in our troubleshooting guide.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/mem0ai/mem0">Mem0</a></b></td><td align="center">61.2k</td><td>Universal memory layer — its proxy-as-write/read-boundary model shaped our memory architecture.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/letta-ai/letta">Letta (MemGPT)</a></b></td><td align="center">23.9k</td><td>Stateful agents with tiered memory — inspired our Context Control & Recovery (CCR) tiered model.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/onestardao/WFGY">WFGY</a></b></td><td align="center">1.8k</td><td>The ProblemMap taxonomy of 16 recurring RAG/LLM failure modes — the shared vocabulary in our troubleshooting guide.</td></tr>
|
||||
</table>
|
||||
|
||||
### 🛰️ Traffic inspection, MITM & transparent proxy
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/chouzz/llm-interceptor">llm-interceptor</a></b></td><td align="center">66</td><td>MITM interception/analysis of coding-assistant ↔ LLM traffic informed early Traffic Inspector requirements. Four previously derived modules — SSE merging, conversation normalization, secret masking and header sanitization — have been replaced by independent clean-room implementations based on public protocol standards. The two host-passthrough surfaces (<code>passthrough.ts</code> and <code>_internal/bypass.cjs</code>) remain OmniRoute-internal implementations classified independently; they were not rewritten as part of that replacement.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/InterceptSuite/ProxyBridge">ProxyBridge</a></b></td><td align="center">5,995</td><td>Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, <code>/proc</code> process attribution and TPROXY capture.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/chouzz/llm-interceptor">llm-interceptor</a></b></td><td align="center">49</td><td>MITM interception/analysis of coding-assistant ↔ LLM traffic — our Traffic Inspector ports its SSE merge, conversation normalization, host passthrough and secret masking (MIT).</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/InterceptSuite/ProxyBridge">ProxyBridge</a></b></td><td align="center">5.5k</td><td>Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, <code>/proc</code> process attribution and TPROXY capture.</td></tr>
|
||||
</table>
|
||||
|
||||
### 📚 Model data, observability & UI
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/anomalyco/models.dev">models.dev</a></b></td><td align="center">6,555</td><td>Open database of AI model specs, pricing and capabilities — synced natively into our model catalog.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/xyflow/xyflow">React Flow / xyflow</a></b></td><td align="center">38,108</td><td>The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/langchain-ai/langgraph">LangGraph</a></b></td><td align="center">40,314</td><td>LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/langfuse/langfuse">Langfuse</a></b></td><td align="center">33,592</td><td>Its trace → span → generation observability model shaped our Compression Studio waterfall.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/kiali/kiali">Kiali</a></b></td><td align="center">3,631</td><td>Istio service-mesh observability — inspired our circuit-breaker badges and error-edge visuals in the Routing/Combo Studio.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/lobehub/lobe-icons">lobe-icons</a></b></td><td align="center">2,428</td><td>AI/LLM brand logos that render the provider icons across our dashboard.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/lipis/flag-icons">flag-icons</a></b></td><td align="center">12,354</td><td>Provides the MIT-licensed SVG flags used by the README language selector.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/anomalyco/models.dev">models.dev</a></b></td><td align="center">6.0k</td><td>Open database of AI model specs, pricing and capabilities — synced natively into our model catalog.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/xyflow/xyflow">React Flow / xyflow</a></b></td><td align="center">37.7k</td><td>The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/langchain-ai/langgraph">LangGraph</a></b></td><td align="center">37.6k</td><td>LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/langfuse/langfuse">Langfuse</a></b></td><td align="center">31.4k</td><td>Its trace → span → generation observability model shaped our Compression Studio waterfall.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/kiali/kiali">Kiali</a></b></td><td align="center">3.6k</td><td>Istio service-mesh observability — inspired our circuit-breaker badges and error-edge visuals in the Routing/Combo Studio.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/lobehub/lobe-icons">lobe-icons</a></b></td><td align="center">2.2k</td><td>AI/LLM brand logos that render the provider icons across our dashboard.</td></tr>
|
||||
</table>
|
||||
|
||||
### 🛡️ Security
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/tldrsec/awesome-secure-defaults">awesome-secure-defaults</a></b></td><td align="center">721</td><td>A curated list of secure-by-default libraries that guides our security choices (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink).</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/tldrsec/awesome-secure-defaults">awesome-secure-defaults</a></b></td><td align="center">710</td><td>A curated list of secure-by-default libraries that guides our security choices (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink).</td></tr>
|
||||
</table>
|
||||
|
||||
### 🧭 Complementary tools
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/BlockRunAI/ClawRouter">ClawRouter</a></b></td><td align="center">6,564</td><td>Inspired request deduplication, emergency zero-cost fallback, pluggable Auto-Combo strategies and multilingual intent classification.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/lbjlaq/Antigravity-Manager">Antigravity-Manager</a></b></td><td align="center">30,652</td><td>Its account-aware model remapping, executable-path validation and plan-label behavior informed OmniRoute's Antigravity runtime.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/jlcodes99/vscode-antigravity-cockpit">vscode-antigravity-cockpit</a></b></td><td align="center">4,817</td><td>Its compact quota-reset countdown format inspired the corresponding provider-limit display in OmniRoute.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/iOfficeAI/AionUi">AionUi</a></b></td><td align="center">32,230</td><td>Its ACP integrations inspired OmniRoute's automatic detection of installed CLI agents.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/steipete/CodexBar">CodexBar</a></b></td><td align="center">20,507</td><td>Identified the Grok Build quota surface; OmniRoute then verified and corrected the live wire format independently.</td></tr>
|
||||
</table>
|
||||
|
||||
## 📄 License
|
||||
@@ -1708,7 +1575,7 @@ MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
**[⬆ Back to top](#-omniroute)** · Built with ❤️ for the open-source AI community.
|
||||
|
||||
<sub>OmniRoute v3.8.51 · Node ≥22.22.2 · MIT License · <a href="https://omniroute.online">omniroute.online</a></sub>
|
||||
<sub>OmniRoute v3.8.49 · Node ≥22.22.2 · MIT License · <a href="https://omniroute.online">omniroute.online</a></sub>
|
||||
|
||||
</div>
|
||||
<!-- GitHub Discussions enabled for community Q&A -->
|
||||
|
||||
@@ -224,14 +224,6 @@ features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends
|
||||
up in `.next/server/*.js` minified chunks. Heuristic supply-chain scanners
|
||||
frequently pattern-match those chunks against malware signatures.
|
||||
|
||||
The scanner configuration we use lives at [`socket.yml`](socket.yml) in the
|
||||
repo root (Socket.dev GitHub App format v2 — see
|
||||
<https://docs.socket.dev/docs/socket-yml>). It explicitly excludes
|
||||
non-shipped directories (`tests/`, `_tasks/`, `_references/`, `_ideia/`,
|
||||
`_mono_repo/`, `docs/`, etc.) so the scanner only reports on code paths that
|
||||
actually reach published users — the scan itself is driven by the Socket
|
||||
GitHub App reading that file, not by a workflow in this repository.
|
||||
|
||||
For each finding category we maintain a per-finding maintainer attestation:
|
||||
|
||||
- **[`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md)** —
|
||||
|
||||
@@ -1,56 +1,10 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## wreq-js 3.2.0 native transport
|
||||
|
||||
OmniRoute ships `wreq-js@3.2.0` and its platform-specific native bindings for browser-
|
||||
fingerprinted HTTP transport. The npm package and all nine binding tarballs are tied by npm SLSA
|
||||
attestations to signed tag `v3.2.0` and immutable source commit
|
||||
[`0d52d5fa252841aeef34d4d063b1766a59612bf7`](https://github.com/sqdshguy/wreq-js/commit/0d52d5fa252841aeef34d4d063b1766a59612bf7).
|
||||
|
||||
- Root tarball:
|
||||
<https://registry.npmjs.org/wreq-js/-/wreq-js-3.2.0.tgz>
|
||||
- npm integrity:
|
||||
`sha512-dawhEbhvd5hxivKZSvv/mAQGO3mwZYESyctOvIIZ/H3DvQJzUM2UoFQsij0fg7hIClQ/GEQgg+2259UcFwhpMQ==`
|
||||
- Exact platform, integrity, size, and SHA-256 receipts for all nine native addons:
|
||||
[`config/release/wreq-js-native-manifest.json`](config/release/wreq-js-native-manifest.json)
|
||||
- Locked per-target Cargo closure, with runtime and compile-only packages kept separate:
|
||||
[`config/release/wreq-js-rust-license-inventory.json`](config/release/wreq-js-rust-license-inventory.json)
|
||||
- Deduplicated license texts and attribution notices for the conservative native runtime closure,
|
||||
including patched BoringSSL, Unicode ICU4X components, and Mozilla root-certificate data:
|
||||
[`config/release/wreq-js-rust-notices.md`](config/release/wreq-js-rust-notices.md)
|
||||
|
||||
The native tarballs themselves contain no LICENSE/NOTICE file. The bundled inventory is therefore
|
||||
shipped beside them. It intentionally over-approximates the locked link-eligible Cargo closure;
|
||||
exact post-LTO membership cannot be claimed without an upstream artifact SBOM/link map or a
|
||||
reproducible-build receipt. The Android addon also dynamically requires `libc++_shared.so`, which
|
||||
is not included in its npm tarball; any artifact that supplies that library needs its separate
|
||||
LLVM/Apache-with-LLVM-exception notice.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 will-work-for-meal
|
||||
Copyright (c) 2025 Oleksandr Herasymov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## codex-chatgpt-web
|
||||
|
||||
Parts of `open-sse/vendor/codex-chatgpt-web/` are adapted from
|
||||
[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web), v4.0.7 commit
|
||||
`b59d7dc51b84fb1f465ff1d00f5207f3b2b4a494`.
|
||||
[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web), commit
|
||||
`55592fca0ba19a27f1b769cec8fff61ff340a785`.
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -70,280 +24,3 @@ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPO
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## blackwell-systems/gcf-typescript
|
||||
|
||||
The generic-profile codec in
|
||||
`open-sse/services/compression/engines/headroom/gcf/{decode_generic,generic,index,scalar}.ts`
|
||||
is adapted from
|
||||
[`blackwell-systems/gcf-typescript`](https://github.com/blackwell-systems/gcf-typescript/tree/00972f2dc781477eb6d369e62edfe03ad4112a07),
|
||||
commit `00972f2dc781477eb6d369e62edfe03ad4112a07`. The license below is reproduced
|
||||
from that commit's
|
||||
[`LICENSE`](https://github.com/blackwell-systems/gcf-typescript/blob/00972f2dc781477eb6d369e62edfe03ad4112a07/LICENSE).
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Dayna Blackwell
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## lipis/flag-icons
|
||||
|
||||
The country flag SVGs in `docs/assets/flags/` are copied from the `flags/4x3/` directory of
|
||||
[`lipis/flag-icons`](https://github.com/lipis/flag-icons/tree/086f7e97d657358203916dbe84f61c2bccaa81eb),
|
||||
commit `086f7e97d657358203916dbe84f61c2bccaa81eb`. The license below is reproduced
|
||||
from that commit's
|
||||
[`LICENSE`](https://github.com/lipis/flag-icons/blob/086f7e97d657358203916dbe84f61c2bccaa81eb/LICENSE).
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013 Panayiotis Lipiridis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## LobeHub provider asset derivatives
|
||||
|
||||
Six local provider SVGs contain geometry derived from fixed components in
|
||||
`@lobehub/icons@5.10.0`. The source package is pinned as follows:
|
||||
|
||||
- Tarball:
|
||||
<https://registry.npmjs.org/@lobehub/icons/-/icons-5.10.0.tgz>
|
||||
- npm shasum: `add1baced073a60157d39c7820b8d5c1928a1054`
|
||||
- npm integrity:
|
||||
`sha512-CIpjkISCLRK7haDtSugGFd0o3odaJts8ewJOkUiEFtns3xvsqbl8i24eowBnjw+yMDQVQyNONlhqTD58YC6Ljg==`
|
||||
- License file in the fixed tarball: `package/LICENSE`
|
||||
|
||||
| Local derivative | Fixed tarball source |
|
||||
| ------------------------------- | ----------------------------------------- |
|
||||
| `public/providers/360ai.svg` | `package/es/Ai360/components/Color.js` |
|
||||
| `public/providers/baichuan.svg` | `package/es/Baichuan/components/Color.js` |
|
||||
| `public/providers/codex.svg` | `package/es/Codex/components/Color.js` |
|
||||
| `public/providers/copilot.svg` | `package/es/Copilot/components/Color.js` |
|
||||
| `public/providers/openclaw.svg` | `package/es/OpenClaw/components/Color.js` |
|
||||
| `public/providers/stepfun.svg` | `package/es/Stepfun/components/Color.js` |
|
||||
|
||||
The fixed tarball contains this license notice:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
This package notice applies to the derived SVG geometry identified above. It does not grant rights
|
||||
in any underlying brand name, logo, or trademark.
|
||||
|
||||
## theSVG provider assets
|
||||
|
||||
At release snapshot `091589089cd134a94df9f6cdab9ba562b2cefd18`, 65 local provider SVGs were
|
||||
byte-exact matches for `public/icons/<slug>/default.svg` in the theSVG repository at immutable
|
||||
commit [`7870bc1c5f657d9accbb7f96cc457b8dd3363ee8`](https://github.com/GLINCKER/thesvg/tree/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8).
|
||||
The fixed upstream evidence includes its
|
||||
[`LICENSE`](https://github.com/GLINCKER/thesvg/blob/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8/LICENSE),
|
||||
[`LEGAL.md`](https://github.com/GLINCKER/thesvg/blob/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8/LEGAL.md),
|
||||
[`TRADEMARK.md`](https://github.com/GLINCKER/thesvg/blob/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8/TRADEMARK.md),
|
||||
[`LICENSING.md`](https://github.com/GLINCKER/thesvg/blob/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8/LICENSING.md),
|
||||
and
|
||||
[`src/data/icons.json`](https://github.com/GLINCKER/thesvg/blob/7870bc1c5f657d9accbb7f96cc457b8dd3363ee8/src/data/icons.json).
|
||||
|
||||
The byte match proves source provenance for the listed files. It does not prove that a registry
|
||||
claim was authorized by each brand owner, and it does not relicense the logos or their underlying
|
||||
brand marks. The theSVG source applies its MIT license to its codebase, tooling, and catalog; its
|
||||
own legal documents separately reserve trademark rights to the respective owners.
|
||||
|
||||
The fixed theSVG source contains this license notice:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 thesvg.org
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### Byte-exact file scope (65/65)
|
||||
|
||||
- `public/providers/alibaba.svg`
|
||||
- `public/providers/anthropic.svg`
|
||||
- `public/providers/arcee.svg`
|
||||
- `public/providers/assemblyai.svg`
|
||||
- `public/providers/aws.svg`
|
||||
- `public/providers/azure.svg`
|
||||
- `public/providers/bailian.svg`
|
||||
- `public/providers/baseten.svg`
|
||||
- `public/providers/cerebras.svg`
|
||||
- `public/providers/cline.svg`
|
||||
- `public/providers/comfyui.svg`
|
||||
- `public/providers/continue.svg`
|
||||
- `public/providers/cursor.svg`
|
||||
- `public/providers/deepgram.svg`
|
||||
- `public/providers/deepinfra.svg`
|
||||
- `public/providers/elevenlabs.svg`
|
||||
- `public/providers/exa.svg`
|
||||
- `public/providers/fal.svg`
|
||||
- `public/providers/fireworks.svg`
|
||||
- `public/providers/friendli.svg`
|
||||
- `public/providers/gemini.svg`
|
||||
- `public/providers/grok.svg`
|
||||
- `public/providers/groq.svg`
|
||||
- `public/providers/heroku.svg`
|
||||
- `public/providers/huggingface.svg`
|
||||
- `public/providers/hyperbolic.svg`
|
||||
- `public/providers/ibm.svg`
|
||||
- `public/providers/inference.svg`
|
||||
- `public/providers/lambda.svg`
|
||||
- `public/providers/longcat.svg`
|
||||
- `public/providers/minimax.svg`
|
||||
- `public/providers/mistral.svg`
|
||||
- `public/providers/moonshot.svg`
|
||||
- `public/providers/morph.svg`
|
||||
- `public/providers/nebius.svg`
|
||||
- `public/providers/novita.svg`
|
||||
- `public/providers/nvidia.svg`
|
||||
- `public/providers/ollama.svg`
|
||||
- `public/providers/openai.svg`
|
||||
- `public/providers/openrouter.svg`
|
||||
- `public/providers/ovhcloud.svg`
|
||||
- `public/providers/picoclaw.svg`
|
||||
- `public/providers/poe.svg`
|
||||
- `public/providers/pollinations.svg`
|
||||
- `public/providers/qwen.svg`
|
||||
- `public/providers/recraft.svg`
|
||||
- `public/providers/replicate.svg`
|
||||
- `public/providers/roocode.svg`
|
||||
- `public/providers/runway.svg`
|
||||
- `public/providers/sambanova.svg`
|
||||
- `public/providers/searchapi.svg`
|
||||
- `public/providers/suno.svg`
|
||||
- `public/providers/tavily.svg`
|
||||
- `public/providers/topazlabs.svg`
|
||||
- `public/providers/trae.svg`
|
||||
- `public/providers/udio.svg`
|
||||
- `public/providers/upstage.svg`
|
||||
- `public/providers/v0.svg`
|
||||
- `public/providers/vercel.svg`
|
||||
- `public/providers/vllm.svg`
|
||||
- `public/providers/volcengine.svg`
|
||||
- `public/providers/voyage.svg`
|
||||
- `public/providers/windsurf.svg`
|
||||
- `public/providers/xai.svg`
|
||||
- `public/providers/zhipu.svg`
|
||||
|
||||
### Upstream registry claims
|
||||
|
||||
These are claims recorded by the fixed upstream registry. They have not been independently
|
||||
verified against an authoritative license or brand-owner notice for every asset, so they are not
|
||||
independent copyright or trademark clearance.
|
||||
|
||||
| Upstream registry claim | Count | Clearance status |
|
||||
| ----------------------- | ----: | --------------------------------------------------------------------- |
|
||||
| MIT | 46 | Upstream claim only; original per-asset copyright notices remain HOLD |
|
||||
| CC0-1.0 | 14 | Upstream claim only; not independently verified with each owner |
|
||||
| Apache-2.0 | 1 | Upstream claim only; upstream NOTICE remains HOLD |
|
||||
| brand-use | 2 | Brand terms, not open-source licenses; owner guidelines remain HOLD |
|
||||
| Custom | 1 | Custom MiniMax claim; terms remain HOLD |
|
||||
| MISSING | 1 | No matching registry claim for HuggingFace; license remains HOLD |
|
||||
|
||||
#### MIT (46)
|
||||
|
||||
`alibaba`, `arcee`, `assemblyai`, `aws`, `bailian`, `baseten`, `cerebras`, `comfyui`,
|
||||
`deepinfra`, `exa`, `fal`, `fireworks`, `friendli`, `gemini`, `grok`, `groq`, `heroku`,
|
||||
`hyperbolic`, `ibm`, `inference`, `lambda`, `longcat`, `mistral`, `moonshot`, `morph`, `nebius`,
|
||||
`novita`, `openai`, `picoclaw`, `pollinations`, `qwen`, `recraft`, `roocode`, `runway`,
|
||||
`sambanova`, `searchapi`, `tavily`, `topazlabs`, `trae`, `udio`, `upstage`, `vllm`, `volcengine`,
|
||||
`voyage`, `xai`, `zhipu`
|
||||
<!-- end:MIT -->
|
||||
|
||||
#### CC0-1.0 (14)
|
||||
|
||||
`anthropic`, `cline`, `cursor`, `deepgram`, `elevenlabs`, `nvidia`, `ollama`, `openrouter`, `poe`,
|
||||
`replicate`, `suno`, `v0`, `vercel`, `windsurf`
|
||||
<!-- end:CC0-1.0 -->
|
||||
|
||||
#### Apache-2.0 (1)
|
||||
|
||||
`continue`
|
||||
<!-- end:Apache-2.0 -->
|
||||
|
||||
#### brand-use (2)
|
||||
|
||||
`azure`, `ovhcloud`
|
||||
<!-- end:brand-use -->
|
||||
|
||||
#### Custom (1)
|
||||
|
||||
`minimax`
|
||||
<!-- end:Custom -->
|
||||
|
||||
#### MISSING (1)
|
||||
|
||||
`huggingface`
|
||||
<!-- end:MISSING -->
|
||||
|
||||
The `continue` Apache-2.0 claim remains HOLD until its authoritative upstream NOTICE obligations
|
||||
are verified. The `azure` and `ovhcloud` brand-use claims are not open-source licenses and remain
|
||||
subject to owner guidelines. `minimax` remains HOLD under custom terms. `huggingface` remains HOLD
|
||||
because its matching file has no entry or license claim in the fixed registry.
|
||||
|
||||
### Trademark and affiliation disclaimer
|
||||
|
||||
All brand names, logos, and trademarks are the property of their respective owners. OmniRoute uses
|
||||
these assets nominatively to identify provider integrations. There is no affiliation, sponsorship,
|
||||
or endorsement by the respective owners. Copyright provenance and source license claims do not
|
||||
provide trademark clearance; users should follow each owner's official brand guidelines.
|
||||
|
||||
@@ -176,14 +176,13 @@ function isWithinRoot(ancestor, candidate) {
|
||||
* Register the ESM resolve hook for the current process. Safe to call multiple
|
||||
* times — subsequent calls are no-ops once the hook is installed.
|
||||
*
|
||||
* Modern runtimes import the hook module in-thread, initialize its root with a
|
||||
* plain function call, and register its synchronous resolver through
|
||||
* `module.registerHooks()`. Runtimes without that API (notably Bun) retain the
|
||||
* `module.register()` worker-thread loader lifecycle path.
|
||||
* Uses Node's stable `module.register()` API (available since Node 20.6,
|
||||
* required Node 22+ here). The hook runs in a worker thread but only reads the
|
||||
* captured `root`, so no shared-state hazards.
|
||||
*
|
||||
* @param {string} root Absolute path to the package root.
|
||||
* @returns {Promise<boolean>} Resolves `true` once registered (or if already
|
||||
* registered), `false` when neither registration API is usable.
|
||||
* registered), `false` on environments where `module.register` is unavailable.
|
||||
*/
|
||||
let _registered = false;
|
||||
export async function registerAliasResolver(root) {
|
||||
@@ -202,7 +201,7 @@ export async function registerAliasResolver(root) {
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await import("node:module");
|
||||
const { register } = await import("node:module");
|
||||
// #7808: load the hook from a real file on disk via pathToFileURL() instead
|
||||
// of building a `data:text/javascript,...` URL dynamically. CodeQL's
|
||||
// `js/incomplete-url-substring-sanitization` flagged the interpolated
|
||||
@@ -212,21 +211,14 @@ export async function registerAliasResolver(root) {
|
||||
// package.json "files": ["bin/"].
|
||||
const hookPath = join(__dirname, "aliasResolverHook.mjs");
|
||||
const hookUrl = pathToFileURL(hookPath);
|
||||
if (typeof mod.registerHooks === "function") {
|
||||
const hook = await import(hookUrl.href);
|
||||
hook.initialize({ root });
|
||||
mod.registerHooks({ resolve: hook.resolve });
|
||||
_registered = true;
|
||||
return true;
|
||||
}
|
||||
mod.register(hookUrl, { data: { root } });
|
||||
register(hookUrl, { data: { root } });
|
||||
_registered = true;
|
||||
return true;
|
||||
} catch {
|
||||
// Runtime or sandboxed env without a usable module hook API — fall back to
|
||||
// the default resolver. The bug will resurface only in the exact
|
||||
// global-install scenario, which is what we explicitly patched; other entry
|
||||
// points still work because they import via relative paths.
|
||||
// Older Node or sandboxed env without module.register — fall back to the
|
||||
// default resolver. The bug will resurface only in the exact global-install
|
||||
// scenario, which is what we explicitly patched; other entry points still
|
||||
// work because they import via relative paths.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,20 +32,17 @@ export function resolveChatGptWebCodexMcpEntry(rootDir = root, exists = existsSy
|
||||
return candidates.find((candidate) => exists(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export async function loadChatGptWebCodexMcpModule(entry) {
|
||||
if (entry.endsWith(".ts")) {
|
||||
await import("tsx/esm");
|
||||
}
|
||||
return import(pathToFileURL(entry).href);
|
||||
}
|
||||
|
||||
export async function startChatGptWebCodexMcp(args = process.argv.slice(2), rootDir = root) {
|
||||
const socketIndex = args.indexOf("--broker-socket");
|
||||
const brokerSocketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined;
|
||||
if (!brokerSocketPath) throw new Error("--broker-socket is required");
|
||||
const entry = resolveChatGptWebCodexMcpEntry(rootDir);
|
||||
if (!entry) throw new Error("ChatGPT Web (Codex) MCP entrypoint was not found");
|
||||
const module = await loadChatGptWebCodexMcpModule(entry);
|
||||
if (entry.endsWith(".ts")) {
|
||||
const { register } = await import("node:module");
|
||||
register("tsx/esm", pathToFileURL(`${rootDir}/`));
|
||||
}
|
||||
const module = await import(pathToFileURL(entry).href);
|
||||
await module.runChatGptMcpServer({ brokerSocketPath });
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ bin/cli/
|
||||
├── provider-test.mjs ← testProviderApiKey()
|
||||
├── settings-store.mjs ← DB CRUD for key_value settings
|
||||
├── locales/
|
||||
│ ├── en.json ← English strings (source of truth, 42 locales)
|
||||
│ ├── en.json ← English strings (source of truth, 43 locales)
|
||||
│ ├── pt-BR.json ← Portuguese (Brazil) — fully translated
|
||||
│ └── {locale}.json ← 41 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
|
||||
│ └── {locale}.json ← 42 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
|
||||
├── scripts/
|
||||
│ └── generate-locales.mjs ← scaffold new locale files from config/i18n.json
|
||||
└── commands/
|
||||
|
||||
@@ -16,7 +16,7 @@ export function register_combos(parent) {
|
||||
});
|
||||
tag.command("post-api-combos")
|
||||
.description("Create routing combo")
|
||||
.requiredOption("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||
.action(async (opts, cmd) => {
|
||||
const gOpts = cmd.optsWithGlobals();
|
||||
let url = "/api/combos";
|
||||
@@ -44,7 +44,7 @@ export function register_combos(parent) {
|
||||
tag.command("put-api-combos-id-")
|
||||
.description("Update combo")
|
||||
.requiredOption("--id <id>", "")
|
||||
.requiredOption("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||
.action(async (opts, cmd) => {
|
||||
const gOpts = cmd.optsWithGlobals();
|
||||
let url = "/api/combos/{id}";
|
||||
@@ -62,7 +62,7 @@ export function register_combos(parent) {
|
||||
tag.command("patch-api-combos-id-")
|
||||
.description("Update combo")
|
||||
.requiredOption("--id <id>", "")
|
||||
.requiredOption("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||
.action(async (opts, cmd) => {
|
||||
const gOpts = cmd.optsWithGlobals();
|
||||
let url = "/api/combos/{id}";
|
||||
@@ -99,17 +99,10 @@ export function register_combos(parent) {
|
||||
});
|
||||
tag.command("post-api-combos-test")
|
||||
.description("Test a combo configuration")
|
||||
.requiredOption("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||
.action(async (opts, cmd) => {
|
||||
const gOpts = cmd.optsWithGlobals();
|
||||
let url = "/api/combos/test";
|
||||
let body;
|
||||
if (opts.body) {
|
||||
body = opts.body.startsWith("@")
|
||||
? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
|
||||
: JSON.parse(opts.body);
|
||||
}
|
||||
const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||
const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||
const data = res.ok ? await res.json() : await res.text();
|
||||
emit(data, gOpts);
|
||||
});
|
||||
|
||||
@@ -93,16 +93,6 @@ export const CLI_TARGET_MANIFEST = Object.freeze({
|
||||
configure: true,
|
||||
runModel: null,
|
||||
}),
|
||||
"5dive": Object.freeze({
|
||||
// 5dive is a fleet manager, not a coding CLI: it points its own `claude`
|
||||
// agents at an endpoint. `omniroute run 5dive` would have nothing to
|
||||
// launch, so this is configure-only.
|
||||
description: "5dive (agent fleet)",
|
||||
aliases: Object.freeze(["fivedive", "5dive-cli"]),
|
||||
run: false,
|
||||
configure: true,
|
||||
runModel: null, // travels as the profile's ANTHROPIC_DEFAULT_*_MODEL
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,15 +22,8 @@ const VALID_FORMATS = new Set(["json", "env"]);
|
||||
const SECURE_FILE_MODE = 0o600;
|
||||
|
||||
export function registerAuthExport(program) {
|
||||
// #11226: `.command("auth export")` does NOT register a two-word command — commander
|
||||
// parses the bare word `export` as a required positional argument of `auth`, so the
|
||||
// action received (exportArgValue, options, command) while expecting (options, command)
|
||||
// and crashed with "cmd.optsWithGlobals is not a function". Register `export` as a
|
||||
// proper nested subcommand instead; the CLI surface stays `omniroute auth export`.
|
||||
program
|
||||
.command("auth")
|
||||
.description(t("authExport.description"))
|
||||
.command("export")
|
||||
.command("auth export")
|
||||
.description(t("authExport.description"))
|
||||
.option("--id <id>", t("authExport.idOpt"))
|
||||
.option("--format <format>", t("authExport.formatOpt"), "json")
|
||||
|
||||
@@ -3,7 +3,6 @@ import { printHeading } from "../io.mjs";
|
||||
import { withRuntime } from "../runtime.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { resolveComboModels, collectModel } from "./comboModels.mjs";
|
||||
|
||||
@@ -64,7 +63,15 @@ export function extendComboSuggest(combo) {
|
||||
weights: opts.weights ? JSON.parse(opts.weights) : undefined,
|
||||
top: opts.top,
|
||||
};
|
||||
const data = await mcpCallTool("omniroute_best_combo_for_task", body);
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_best_combo_for_task", arguments: body },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
const candidates = data.candidates ?? data;
|
||||
const rows = (Array.isArray(candidates) ? candidates : []).map((c, i) => ({
|
||||
rank: i + 1,
|
||||
@@ -300,12 +307,6 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
|
||||
}
|
||||
|
||||
const models = Array.isArray(opts.models) ? opts.models : [];
|
||||
if (!models.length) {
|
||||
console.error(
|
||||
"combo create requires at least one target. Pass --models <provider/model,...> and/or repeat --model <provider/model>."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
return await withRuntime(async ({ kind, api, db }) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -79,17 +78,18 @@ async function restComboStats(period) {
|
||||
}
|
||||
|
||||
async function mcpCall(name, args, restFallback) {
|
||||
try {
|
||||
return await mcpCallTool(name, args);
|
||||
} catch (err) {
|
||||
// Keep the REST fallback behavior for builds where the MCP surface
|
||||
// is unreachable / not mounted. Anything else rethrows as an error.
|
||||
const status = err?.status || err?.cause?.status;
|
||||
if ((status === 404 || status === 501) && typeof restFallback === "function") {
|
||||
return restFallback();
|
||||
}
|
||||
throw err;
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name, arguments: args },
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
// 404 = MCP tool surface not mounted on this build; 501 = not implemented.
|
||||
// Anything else is a genuine error and we surface it.
|
||||
if ((res.status === 404 || res.status === 501) && typeof restFallback === "function") {
|
||||
return restFallback();
|
||||
}
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function confirm(q) {
|
||||
|
||||
@@ -16,29 +16,6 @@ function ensureBackup(configPath) {
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function mergeClaudeSettings(existingContent, generatedContent) {
|
||||
const generated = JSON.parse(generatedContent);
|
||||
let current = {};
|
||||
if (existingContent && existingContent.trim()) {
|
||||
current = JSON.parse(existingContent);
|
||||
if (!current || typeof current !== "object" || Array.isArray(current)) current = {};
|
||||
}
|
||||
return JSON.stringify(
|
||||
{
|
||||
...current,
|
||||
...generated,
|
||||
env: {
|
||||
...(current.env && typeof current.env === "object" && !Array.isArray(current.env)
|
||||
? current.env
|
||||
: {}),
|
||||
...(generated.env || {}),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
async function runConfigListCommand(opts = {}) {
|
||||
const { detectAllTools } = await import("../../../src/lib/cli-helper/tool-detector.ts");
|
||||
const tools = await detectAllTools();
|
||||
@@ -143,12 +120,7 @@ async function runConfigSetCommand(toolId, opts = {}) {
|
||||
const backupPath = ensureBackup(result.configPath);
|
||||
if (backupPath) printInfo(`Backup saved to: ${backupPath}`);
|
||||
|
||||
let content = result.content;
|
||||
if (toolId === "claude" && fs.existsSync(result.configPath)) {
|
||||
content = mergeClaudeSettings(fs.readFileSync(result.configPath, "utf-8"), result.content);
|
||||
}
|
||||
|
||||
fs.writeFileSync(result.configPath, content, "utf-8");
|
||||
fs.writeFileSync(result.configPath, result.content, "utf-8");
|
||||
printSuccess(`Config written to ${result.configPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ export const SETUP_MODULES = {
|
||||
cline: { module: "./setup-cline.mjs", exportName: "runSetupClineCommand" },
|
||||
continue: { module: "./setup-continue.mjs", exportName: "runSetupContinueCommand" },
|
||||
kilo: { module: "./setup-kilo.mjs", exportName: "runSetupKiloCommand" },
|
||||
"5dive": { module: "./setup-5dive.mjs", exportName: "runSetup5diveCommand" },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -61,12 +61,27 @@ export function registerMcp(program) {
|
||||
? JSON.parse(argsPositional)
|
||||
: {};
|
||||
|
||||
const exitCode = await runMcpCallCommand(tool, args, {
|
||||
...opts,
|
||||
stream: opts.stream,
|
||||
}, globalOpts);
|
||||
if (opts.stream) {
|
||||
await runMcpStream(tool, args, globalOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
const extraHeaders = opts.scope?.length ? { "X-MCP-Scopes": opts.scope.join(",") } : {};
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: tool, arguments: args },
|
||||
headers: extraHeaders,
|
||||
});
|
||||
if (res.status === 403) {
|
||||
process.stderr.write("Scope denied\n");
|
||||
process.exit(4);
|
||||
}
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
emit(data, globalOpts);
|
||||
});
|
||||
|
||||
mcp
|
||||
@@ -84,132 +99,112 @@ export function registerMcp(program) {
|
||||
const data = await res.json();
|
||||
emit(data.scopes ?? data, cmd.optsWithGlobals());
|
||||
});
|
||||
|
||||
// 5.2 — mcp tools + mcp audit
|
||||
const tools = mcp.command("tools").description(t("mcp.tools.description"));
|
||||
|
||||
tools
|
||||
.command("list")
|
||||
.description(t("mcp.tools.list.description"))
|
||||
.option("--scope <s>", t("mcp.tools.list.scope"))
|
||||
.action(async (opts, cmd) => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.scope) params.set("scope", opts.scope);
|
||||
const res = await apiFetch(`/api/mcp/tools?${params}`);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
emit(data.tools ?? data, cmd.optsWithGlobals(), mcpToolSchema);
|
||||
});
|
||||
|
||||
tools
|
||||
.command("info <name>")
|
||||
.description(t("mcp.tools.info.description"))
|
||||
.action(async (name, opts, cmd) => {
|
||||
const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}`);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Not found: ${name}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(await res.json(), cmd.optsWithGlobals());
|
||||
});
|
||||
|
||||
tools
|
||||
.command("schema <name>")
|
||||
.description(t("mcp.tools.schema.description"))
|
||||
.option("--io <kind>", t("mcp.tools.schema.io"), "input")
|
||||
.action(async (name, opts, cmd) => {
|
||||
const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}&io=${opts.io}`);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Not found: ${name}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
if (globalOpts.output === "json") {
|
||||
process.stdout.write(JSON.stringify(data.schema ?? data, null, 2) + "\n");
|
||||
} else {
|
||||
emit(data.schema ?? data, globalOpts);
|
||||
}
|
||||
});
|
||||
|
||||
const audit = mcp.command("audit").description(t("mcp.audit.description"));
|
||||
|
||||
audit
|
||||
.command("tail")
|
||||
.option("--follow", t("audit.tail.follow"))
|
||||
.option("--limit <n>", t("audit.tail.limit"), parseInt, 100)
|
||||
.action(async (opts, cmd) => {
|
||||
const { runAuditTail } = await import("./audit.mjs");
|
||||
await runAuditTail({ ...opts, source: "mcp" }, cmd);
|
||||
});
|
||||
|
||||
audit
|
||||
.command("stats")
|
||||
.option("--period <p>", t("audit.stats.period"), "7d")
|
||||
.action(async (opts, cmd) => {
|
||||
const res = await apiFetch(`/api/mcp/audit/stats?period=${opts.period}`);
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(await res.json(), cmd.optsWithGlobals());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared JSON-RPC 2.0 MCP client used by both stream and non-stream `mcp call`.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. POST /api/mcp/stream with initialize → get Mcp-Session-Id header
|
||||
* 2. POST /api/mcp/stream with tools/call + Mcp-Session-Id header
|
||||
*
|
||||
* When `stream` is true, writes SSE data chunks to stdout as they arrive.
|
||||
* When `stream` is false, returns the parsed JSON-RPC result.
|
||||
*
|
||||
* Returns the exit code (0 = success, non-zero = failure).
|
||||
*/
|
||||
async function mcpJsonRpcCall(tool, args, { stream = false, globalOpts = {} } = {}) {
|
||||
async function runMcpStream(tool, args, globalOpts) {
|
||||
const baseUrl = globalOpts.baseUrl ?? "http://localhost:20128";
|
||||
const apiKey = globalOpts.apiKey ?? "";
|
||||
const streamUrl = `${baseUrl}/api/mcp/stream`;
|
||||
|
||||
const hdrs = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: stream ? "text/event-stream" : "application/json",
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
};
|
||||
|
||||
// Step 1 — initialize
|
||||
const initRes = await fetch(streamUrl, {
|
||||
const res = await fetch(`${baseUrl}/api/mcp/stream`, {
|
||||
method: "POST",
|
||||
headers: hdrs,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "omniroute-cli", version: "1.0" },
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ name: tool, arguments: args }),
|
||||
});
|
||||
|
||||
if (!initRes.ok) {
|
||||
const text = await initRes.text().catch(() => "");
|
||||
process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? ` — ${text}` : ""}\n`);
|
||||
return 1;
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`HTTP ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sessionId = initRes.headers.get("mcp-session-id");
|
||||
if (!sessionId) {
|
||||
process.stderr.write("MCP initialize failed: no Mcp-Session-Id in response\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Step 2 — tools/call
|
||||
const callHeaders = {
|
||||
...hdrs,
|
||||
"mcp-session-id": sessionId,
|
||||
};
|
||||
|
||||
const callRes = await fetch(streamUrl, {
|
||||
method: "POST",
|
||||
headers: callHeaders,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: { name: tool, arguments: args },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!callRes.ok) {
|
||||
const text = await callRes.text().catch(() => "");
|
||||
process.stderr.write(`MCP call failed: HTTP ${callRes.status}${text ? ` — ${text}` : ""}\n`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
return readMcpSseStream(callRes.body);
|
||||
}
|
||||
|
||||
// Non-stream: parse JSON-RPC response
|
||||
const data = await callRes.json();
|
||||
if (data.error) {
|
||||
process.stderr.write(`MCP error: ${data.error.message || JSON.stringify(data.error)}\n`);
|
||||
return 1;
|
||||
}
|
||||
// Print the result content
|
||||
const content = data.result?.content;
|
||||
if (content) {
|
||||
for (const item of content) {
|
||||
if (item.type === "text") {
|
||||
process.stdout.write(item.text + "\n");
|
||||
} else if (item.type === "resource") {
|
||||
process.stdout.write(JSON.stringify(item.resource) + "\n");
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(item) + "\n");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(data.result, null, 2) + "\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function readMcpSseStream(body) {
|
||||
if (!body) return 1;
|
||||
const reader = body.getReader();
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
}
|
||||
const lines = buf.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runMcpCallCommand(tool, args, opts = {}, globalOpts = {}) {
|
||||
return mcpJsonRpcCall(tool, args, { stream: opts.stream, globalOpts });
|
||||
}
|
||||
|
||||
export async function runMcpStatusCommand(opts = {}) {
|
||||
@@ -238,8 +233,7 @@ export async function runMcpStatusCommand(opts = {}) {
|
||||
}
|
||||
|
||||
const transport = status.transport || "stdio";
|
||||
const online = status.online ?? status.running;
|
||||
console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped"));
|
||||
console.log(status.running ? t("mcp.running", { transport }) : t("mcp.stopped"));
|
||||
if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
|
||||
if (status.scopes?.length) {
|
||||
console.log(" Scopes:");
|
||||
|
||||
@@ -24,80 +24,6 @@ function parseHeader(kv) {
|
||||
return { name: kv.slice(0, eq), value: kv.slice(eq + 1) };
|
||||
}
|
||||
|
||||
function getRootCommand(cmd) {
|
||||
let curr = cmd;
|
||||
while (curr.parent) curr = curr.parent;
|
||||
return curr;
|
||||
}
|
||||
|
||||
function resolveNodeEndpoint(opts, cmd) {
|
||||
if (opts.endpoint) {
|
||||
return { endpoint: opts.endpoint, apiFetchOpts: cmd.optsWithGlobals() };
|
||||
}
|
||||
if (opts.nodeUrl) {
|
||||
return { endpoint: opts.nodeUrl, apiFetchOpts: cmd.optsWithGlobals() };
|
||||
}
|
||||
|
||||
// Check if --base-url, --endpoint, or --node-url was explicitly passed after the subcommand
|
||||
const root = getRootCommand(cmd);
|
||||
const rawArgs = root.rawArgs || process.argv;
|
||||
const cmdName = cmd.name();
|
||||
|
||||
let subArgsStart = -1;
|
||||
for (let i = 0; i < rawArgs.length - 1; i++) {
|
||||
if (rawArgs[i] === "nodes" || rawArgs[i] === "provider-nodes") {
|
||||
if (rawArgs[i + 1] === cmdName) {
|
||||
subArgsStart = i + 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let explicitSubcommandBaseUrl = undefined;
|
||||
let serverBaseUrl = undefined;
|
||||
|
||||
if (subArgsStart !== -1) {
|
||||
const preArgs = rawArgs.slice(0, subArgsStart);
|
||||
for (let i = 0; i < preArgs.length; i++) {
|
||||
if (preArgs[i] === "--base-url" && i + 1 < preArgs.length) {
|
||||
serverBaseUrl = preArgs[i + 1];
|
||||
} else if (preArgs[i].startsWith("--base-url=")) {
|
||||
serverBaseUrl = preArgs[i].slice("--base-url=".length);
|
||||
}
|
||||
}
|
||||
|
||||
const subArgs = rawArgs.slice(subArgsStart);
|
||||
for (let i = 0; i < subArgs.length; i++) {
|
||||
const arg = subArgs[i];
|
||||
if (
|
||||
(arg === "--base-url" || arg === "--endpoint" || arg === "--node-url") &&
|
||||
i + 1 < subArgs.length
|
||||
) {
|
||||
explicitSubcommandBaseUrl = subArgs[i + 1];
|
||||
} else if (
|
||||
arg.startsWith("--base-url=") ||
|
||||
arg.startsWith("--endpoint=") ||
|
||||
arg.startsWith("--node-url=")
|
||||
) {
|
||||
explicitSubcommandBaseUrl = arg.slice(arg.indexOf("=") + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (explicitSubcommandBaseUrl !== undefined) {
|
||||
const globals = cmd.optsWithGlobals?.() ?? {};
|
||||
const apiFetchOpts = { ...globals };
|
||||
if (serverBaseUrl) {
|
||||
apiFetchOpts.baseUrl = serverBaseUrl;
|
||||
} else {
|
||||
delete apiFetchOpts.baseUrl;
|
||||
}
|
||||
return { endpoint: explicitSubcommandBaseUrl, apiFetchOpts };
|
||||
}
|
||||
|
||||
return { endpoint: undefined, apiFetchOpts: cmd.optsWithGlobals() };
|
||||
}
|
||||
|
||||
const nodeSchema = [
|
||||
{ key: "id", header: "Node ID", width: 22 },
|
||||
{ key: "provider", header: "Provider", width: 16 },
|
||||
@@ -144,8 +70,7 @@ export function registerNodes(program) {
|
||||
nodes
|
||||
.command("add")
|
||||
.requiredOption("--provider <p>", t("nodes.add.provider"))
|
||||
.option("--endpoint <url>", t("nodes.add.baseUrl"))
|
||||
.option("--base-url <url>", t("nodes.add.baseUrl"))
|
||||
.requiredOption("--base-url <url>", t("nodes.add.baseUrl"))
|
||||
.option("--name <n>", t("nodes.add.name"))
|
||||
.option("--weight <w>", t("nodes.add.weight"), parseInt, 100)
|
||||
.option("--region <r>", t("nodes.add.region"))
|
||||
@@ -156,57 +81,41 @@ export function registerNodes(program) {
|
||||
[]
|
||||
)
|
||||
.action(async (opts, cmd) => {
|
||||
const { endpoint, apiFetchOpts } = resolveNodeEndpoint(opts, cmd);
|
||||
if (!endpoint) {
|
||||
process.stderr.write(`error: required option '--endpoint <url>' or '--base-url <url>' not specified\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const body = {
|
||||
provider: opts.provider,
|
||||
baseUrl: endpoint,
|
||||
baseUrl: opts.baseUrl,
|
||||
name: opts.name,
|
||||
weight: opts.weight,
|
||||
region: opts.region,
|
||||
enabled: true,
|
||||
headers: opts.authHeader?.length ? opts.authHeader : undefined,
|
||||
};
|
||||
const res = await apiFetch("/api/provider-nodes", {
|
||||
...apiFetchOpts,
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
const res = await apiFetch("/api/provider-nodes", { method: "POST", body });
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(await res.json(), apiFetchOpts);
|
||||
emit(await res.json(), cmd.optsWithGlobals());
|
||||
});
|
||||
|
||||
nodes
|
||||
.command("update <nodeId>")
|
||||
.option("--endpoint <url>", t("nodes.update.baseUrl"))
|
||||
.option("--base-url <url>", t("nodes.update.baseUrl"))
|
||||
.option("--name <n>", t("nodes.update.name"))
|
||||
.option("--weight <w>", t("nodes.update.weight"), parseInt)
|
||||
.option("--region <r>", t("nodes.update.region"))
|
||||
.option("--enabled <b>", t("nodes.update.enabled"), (v) => v === "true")
|
||||
.action(async (id, opts, cmd) => {
|
||||
const { endpoint, apiFetchOpts } = resolveNodeEndpoint(opts, cmd);
|
||||
const body = {};
|
||||
if (endpoint !== undefined) body.baseUrl = endpoint;
|
||||
for (const k of ["name", "weight", "region", "enabled"]) {
|
||||
for (const k of ["baseUrl", "name", "weight", "region", "enabled"]) {
|
||||
if (opts[k] !== undefined) body[k] = opts[k];
|
||||
}
|
||||
const res = await apiFetch(`/api/provider-nodes/${id}`, {
|
||||
...apiFetchOpts,
|
||||
method: "PUT",
|
||||
body,
|
||||
});
|
||||
const res = await apiFetch(`/api/provider-nodes/${id}`, { method: "PUT", body });
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(await res.json(), apiFetchOpts);
|
||||
emit(await res.json(), cmd.optsWithGlobals());
|
||||
});
|
||||
|
||||
nodes
|
||||
@@ -227,25 +136,18 @@ export function registerNodes(program) {
|
||||
|
||||
nodes
|
||||
.command("validate")
|
||||
.option("--endpoint <url>", t("nodes.validate.baseUrl"))
|
||||
.option("--base-url <url>", t("nodes.validate.baseUrl"))
|
||||
.requiredOption("--base-url <url>", t("nodes.validate.baseUrl"))
|
||||
.requiredOption("--provider <p>", t("nodes.validate.provider"))
|
||||
.action(async (opts, cmd) => {
|
||||
const { endpoint, apiFetchOpts } = resolveNodeEndpoint(opts, cmd);
|
||||
if (!endpoint) {
|
||||
process.stderr.write(`error: required option '--endpoint <url>' or '--base-url <url>' not specified\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const res = await apiFetch("/api/provider-nodes/validate", {
|
||||
...apiFetchOpts,
|
||||
method: "POST",
|
||||
body: { baseUrl: endpoint, provider: opts.provider },
|
||||
body: { baseUrl: opts.baseUrl, provider: opts.provider },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
emit(await res.json(), apiFetchOpts);
|
||||
emit(await res.json(), cmd.optsWithGlobals());
|
||||
});
|
||||
|
||||
nodes
|
||||
|
||||
@@ -228,37 +228,20 @@ async function runSocialFlow(def, opts) {
|
||||
|
||||
async function runDeviceFlow(def, opts) {
|
||||
const providerKey = resolveBackendKey(def.id);
|
||||
let startRes = await apiFetch(`/api/oauth/${providerKey}/device-code`, targetApiOptions(opts));
|
||||
if (!startRes.ok) {
|
||||
startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
});
|
||||
if (!startRes.ok) {
|
||||
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const start = await startRes.json();
|
||||
const userCode = start.userCode ?? start.user_code ?? "";
|
||||
const verificationUri =
|
||||
start.verificationUriComplete ??
|
||||
start.verification_uri_complete ??
|
||||
start.verificationUri ??
|
||||
start.verification_uri ??
|
||||
start.authUrl ??
|
||||
start.url ??
|
||||
"";
|
||||
|
||||
if (userCode) {
|
||||
process.stdout.write(`\nDevice code: ${userCode}\nVisit: ${verificationUri}\n\n`);
|
||||
} else if (verificationUri) {
|
||||
process.stdout.write(`\nVisit: ${verificationUri}\n\n`);
|
||||
} else {
|
||||
process.stdout.write(`\nAuthorization URL not available\n\n`);
|
||||
}
|
||||
|
||||
if (opts.browser !== false && verificationUri) await openBrowser(verificationUri);
|
||||
process.stdout.write(
|
||||
`\nDevice code: ${start.userCode ?? start.user_code ?? ""}\nVisit: ${start.verificationUri ?? start.verification_uri}\n\n`
|
||||
);
|
||||
if (opts.browser !== false)
|
||||
await openBrowser(start.verificationUri ?? start.verification_uri ?? "");
|
||||
process.stderr.write("Waiting for device authorization...\n");
|
||||
const deadline = Date.now() + (opts.timeout ?? 300000);
|
||||
const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
|
||||
@@ -319,18 +302,7 @@ export async function runOAuthStatus(opts, cmd) {
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
const payload = data?.connections ?? data?.providers ?? data?.items ?? data;
|
||||
// #11236 (bug 5 residual): a 200 whose body is out of contract (no
|
||||
// connections/providers/items array — e.g. `{"status":"ok"}`) used to fall
|
||||
// through to `.filter` on a non-array and crash with a bare TypeError plus a
|
||||
// libuv teardown assertion on Windows. Coerce to an empty list with a
|
||||
// sanitized one-line warning instead of dumping a stack trace.
|
||||
if (!Array.isArray(payload)) {
|
||||
process.stderr.write(
|
||||
"Warning: unexpected response shape from /api/providers; showing no connections.\n"
|
||||
);
|
||||
}
|
||||
const connections = (Array.isArray(payload) ? payload : []).filter(
|
||||
const connections = (data.connections ?? data.providers ?? data.items ?? data).filter(
|
||||
(c) => c.authType === "oauth" || c.authType === "oauth2"
|
||||
);
|
||||
emit(connections, globalOpts, connectionSchema);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -9,7 +8,15 @@ function fmtTs(v) {
|
||||
}
|
||||
|
||||
async function mcpCall(name, args) {
|
||||
return mcpCallTool(name, args);
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name, arguments: args },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`MCP error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const proxySchema = [
|
||||
|
||||
@@ -9,13 +9,10 @@ import { discoverPlugins } from "../plugins.mjs";
|
||||
// (instead of string-interpolating into `execSync`) prevents a malicious plugin
|
||||
// name like `foo; rm -rf ~` or `` foo`id` `` from being interpreted by the shell.
|
||||
function runNpm(args) {
|
||||
const isBun = Boolean(process.versions.bun);
|
||||
const pm = isBun ? "bun" : "npm";
|
||||
const cmdArgs = isBun && args[0] === "install" ? ["add", ...args.slice(1)] : args;
|
||||
const res = spawnSync(pm, cmdArgs, { stdio: "inherit", shell: false });
|
||||
const res = spawnSync("npm", args, { stdio: "inherit", shell: false });
|
||||
if (res.error) throw res.error;
|
||||
if (typeof res.status === "number" && res.status !== 0) {
|
||||
throw new Error(`${pm} exited with code ${res.status}`);
|
||||
throw new Error(`npm exited with code ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ import { registerSetupClaude } from "./setup-claude.mjs";
|
||||
import { registerSetupOpencode } from "./setup-opencode.mjs";
|
||||
import { registerSetupCline } from "./setup-cline.mjs";
|
||||
import { registerSetupKilo } from "./setup-kilo.mjs";
|
||||
import { registerSetup5dive } from "./setup-5dive.mjs";
|
||||
import { registerSetupContinue } from "./setup-continue.mjs";
|
||||
import { registerSetupCursor } from "./setup-cursor.mjs";
|
||||
import { registerSetupRoo } from "./setup-roo.mjs";
|
||||
@@ -153,7 +152,6 @@ export function registerCommands(program) {
|
||||
registerSetupOpencode(program);
|
||||
registerSetupCline(program);
|
||||
registerSetupKilo(program);
|
||||
registerSetup5dive(program);
|
||||
registerSetupContinue(program);
|
||||
registerSetupCursor(program);
|
||||
registerSetupRoo(program);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { Argument } from "commander";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -167,7 +166,14 @@ export function registerResilience(program) {
|
||||
])
|
||||
)
|
||||
.action(async (name, opts, cmd) => {
|
||||
await mcpCallTool("omniroute_set_resilience_profile", { profile: name });
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_set_resilience_profile", arguments: { profile: name } },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`Profile: ${name}\n`);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { platform, totalmem } from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
|
||||
import {
|
||||
ServerSupervisor,
|
||||
detectMitmCrash,
|
||||
BUN_PRELOAD_PATH,
|
||||
} from "../runtime/processSupervisor.mjs";
|
||||
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
|
||||
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
|
||||
import {
|
||||
ensureAndroidCacheDir,
|
||||
isFatalInstrumentationHookFailure,
|
||||
formatAndroidInstrumentationFailureHint,
|
||||
} from "../utils/ensureAndroidCacheDir.mjs";
|
||||
import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs";
|
||||
import { resolveServerHost } from "../utils/serverHost.mjs";
|
||||
import {
|
||||
resolveMaxOldSpaceMb,
|
||||
calibrateHeapFallbackMb,
|
||||
@@ -24,7 +20,6 @@ import {
|
||||
buildNodeHeapArgs,
|
||||
} from "../../../scripts/build/runtime-env.mjs";
|
||||
import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs";
|
||||
import { startDetachedTray, validateTrayOptions } from "../tray/detachedTray.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "package.json"), "utf8"));
|
||||
@@ -47,7 +42,7 @@ function parsePort(value, fallback) {
|
||||
}
|
||||
|
||||
export function registerServe(program) {
|
||||
const command = program
|
||||
program
|
||||
.command("serve", { isDefault: true })
|
||||
.description(t("serve.description"))
|
||||
.option("--port <port>", t("serve.port"))
|
||||
@@ -56,7 +51,7 @@ export function registerServe(program) {
|
||||
.option("--log", t("serve.log"))
|
||||
.option("--no-recovery", t("serve.no_recovery"))
|
||||
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
|
||||
.option("--tray", t("serve.tray") || "Start in the system tray (desktop only)")
|
||||
.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>",
|
||||
@@ -71,9 +66,6 @@ export function registerServe(program) {
|
||||
.action(async (opts) => {
|
||||
await runServe(opts);
|
||||
});
|
||||
command.addOption(command.createOption("--tray-worker").hideHelp());
|
||||
command.addOption(command.createOption("--tray-ready-port <port>").hideHelp());
|
||||
command.addOption(command.createOption("--tray-ready-token <token>").hideHelp());
|
||||
}
|
||||
|
||||
/** Once-per-process guard so the Android/Termux cache hint is not spammed. */
|
||||
@@ -103,32 +95,6 @@ export function resetInstrumentationFailureHintForTests() {
|
||||
export async function runServe(opts = {}) {
|
||||
const startedAt = performance.now();
|
||||
|
||||
const trayOptionError = validateTrayOptions(opts);
|
||||
if (trayOptionError) throw new Error(trayOptionError);
|
||||
|
||||
if (opts.tray === true && opts.trayWorker !== true) {
|
||||
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128);
|
||||
const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT;
|
||||
const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY;
|
||||
urlScheme = resolveTlsOptions({
|
||||
...process.env,
|
||||
...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}),
|
||||
...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}),
|
||||
})
|
||||
? "https"
|
||||
: "http";
|
||||
const result = await startDetachedTray({
|
||||
cliPath: join(ROOT, "bin", "omniroute.mjs"),
|
||||
port,
|
||||
maxRestarts: opts.maxRestarts ?? 2,
|
||||
tlsCert,
|
||||
tlsKey,
|
||||
});
|
||||
console.log(`\x1b[32m✔ OmniRoute tray started in background\x1b[0m`);
|
||||
console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${port}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Same prep as bin/omniroute.mjs — keep it here so a direct `runServe()` call
|
||||
// (tests / programmatic) still gets a writable Next.js cache dir before spawn.
|
||||
ensureAndroidCacheDir({ env: process.env });
|
||||
@@ -166,15 +132,6 @@ export async function runServe(opts = {}) {
|
||||
`);
|
||||
}
|
||||
|
||||
// GHSA-wmgv-ph3p-rv57: the default posture (all interfaces + no API key) is a
|
||||
// deliberate local-first choice, but it must be loud at startup — an operator
|
||||
// on an untrusted network learns the two escape hatches here, not after a
|
||||
// surprise quota bill.
|
||||
const exposureWarning = resolveExposureWarning();
|
||||
if (exposureWarning) {
|
||||
console.warn(`\x1b[33m ⚠ ${exposureWarning}\x1b[0m\n`);
|
||||
}
|
||||
|
||||
const serverWsJs = join(APP_DIR, "server-ws.mjs");
|
||||
const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js");
|
||||
|
||||
@@ -298,8 +255,7 @@ export async function runServe(opts = {}) {
|
||||
opts.log === true,
|
||||
opts.maxRestarts ?? 2,
|
||||
startedAt,
|
||||
useTray,
|
||||
{ trayReadyPort: opts.trayReadyPort, trayReadyToken: opts.trayReadyToken }
|
||||
useTray
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,7 +266,7 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[
|
||||
...(process.versions.bun
|
||||
? ["--preload", BUN_PRELOAD_PATH]
|
||||
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")]
|
||||
: buildNodeHeapArgs(process.env, memoryLimit)),
|
||||
serverJs,
|
||||
],
|
||||
@@ -335,7 +291,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[
|
||||
...(process.versions.bun
|
||||
? ["--preload", BUN_PRELOAD_PATH]
|
||||
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")]
|
||||
: buildNodeHeapArgs(process.env, memoryLimit)),
|
||||
serverJs,
|
||||
],
|
||||
@@ -412,11 +368,9 @@ async function runWithSupervisor(
|
||||
showLog,
|
||||
maxRestarts,
|
||||
startedAt,
|
||||
useTray = false,
|
||||
{ trayReadyPort, trayReadyToken } = {}
|
||||
useTray = false
|
||||
) {
|
||||
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
|
||||
writePidFile("supervisor", process.pid);
|
||||
|
||||
const supervisor = new ServerSupervisor({
|
||||
serverPath: serverJs,
|
||||
@@ -427,9 +381,7 @@ async function runWithSupervisor(
|
||||
if (detectMitmCrash(crashLog)) {
|
||||
try {
|
||||
const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const { updateSettings } = await import(
|
||||
pathToFileURL(join(PROJECT_ROOT, "src/lib/db/settings.ts")).href
|
||||
);
|
||||
const { updateSettings } = await import(`${PROJECT_ROOT}/src/lib/db/settings.ts`);
|
||||
updateSettings({ mitmEnabled: false });
|
||||
} catch {}
|
||||
return "disable-mitm-and-retry";
|
||||
@@ -442,38 +394,17 @@ async function runWithSupervisor(
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
});
|
||||
|
||||
if (!showLog) {
|
||||
waitForServer(dashboardPort, 60000).then(async (up) => {
|
||||
if (up) {
|
||||
if (useTray) {
|
||||
const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
if (!trayReady) {
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (trayReadyPort && trayReadyToken) {
|
||||
const { notifyTrayReady } = await import("../tray/detachedTray.mjs");
|
||||
try {
|
||||
await notifyTrayReady(parsePort(trayReadyPort, 0), trayReadyToken);
|
||||
} catch {
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
} else {
|
||||
reportReadinessTimeout(dashboardPort, supervisor);
|
||||
@@ -520,30 +451,29 @@ function killTrayIfActive() {
|
||||
async function maybeStartTray(port, apiPort, supervisor) {
|
||||
try {
|
||||
const { initTray, isTraySupported } = await import("../tray/index.mjs");
|
||||
if (!isTraySupported()) return false;
|
||||
if (!isTraySupported()) return;
|
||||
const { default: open } = await import("open").catch(() => ({ default: null }));
|
||||
const dashboardUrl = `${urlScheme}://localhost:${port}`;
|
||||
const tray = await initTray({
|
||||
port,
|
||||
onQuit: () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
},
|
||||
onOpenDashboard: () => open?.(dashboardUrl),
|
||||
onShowLogs: () => open?.(`${dashboardUrl}/dashboard/logs`),
|
||||
onShowLogs: () => {
|
||||
// In-place: open logs stream (best-effort)
|
||||
process.stdout.write(`[omniroute][tray] Logs at: ${dashboardUrl}/logs\n`);
|
||||
},
|
||||
});
|
||||
if (tray) {
|
||||
const { killTray } = await import("../tray/index.mjs");
|
||||
_killTray = killTray;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} 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`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
/**
|
||||
* omniroute setup-5dive — point a 5dive agent fleet at OmniRoute.
|
||||
*
|
||||
* 5dive (https://5dive.com) manages a fleet of long-running coding agents, each
|
||||
* one a systemd unit under its own Unix user. It is not itself a coding CLI, so
|
||||
* there is nothing for `omniroute run` to launch — this is a configure-only
|
||||
* target.
|
||||
*
|
||||
* Unlike the other recipes, 5dive does not read a config file out of $HOME. Its
|
||||
* credentials live in AUTH PROFILES under /var/lib/5dive/auth-profiles/<name>/,
|
||||
* and the supported way to write one is the CLI itself:
|
||||
*
|
||||
* 5dive agent auth set claude --provider=<id> --base-url=<url> \
|
||||
* --api-key=- --auth-profile=<name> --model=<slug>
|
||||
*
|
||||
* Four value flags, all four load-bearing (verified against 5dive-cli main,
|
||||
* 2026-08-27):
|
||||
* --provider `--base-url` is refused without it, rather than accepted
|
||||
* and silently dropped. `openai` here is 5dive's BYO id for
|
||||
* "a custom Anthropic-compatible endpoint", not a vendor
|
||||
* choice — override with --byo-provider.
|
||||
* --base-url OmniRoute's Anthropic surface, ROOT url with no /v1.
|
||||
* --auth-profile BYO credentials are profile-scoped; required for claude.
|
||||
* --model `openai` has no row in 5dive's built-in endpoint catalog,
|
||||
* so there are no per-tier model ids to inherit.
|
||||
*
|
||||
* The key is handed over on stdin (`--api-key=-`) so it never reaches argv.
|
||||
*
|
||||
* Two things this recipe cannot do for you, and says so instead of failing
|
||||
* obscurely:
|
||||
* 1. Writing an auth profile is root-only on the 5dive host. We re-exec
|
||||
* through sudo when we are not root (disable with --no-sudo).
|
||||
* 2. `agent auth set` writes the profile and restarts the agents bound to it,
|
||||
* but each seat also carries its OWN runtime model pin, and that pin wins
|
||||
* over the profile's ANTHROPIC_DEFAULT_*_MODEL. Pass --agent <name> (repeatable)
|
||||
* to pin the seats too; otherwise we print the command for them.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
const DEFAULT_PROFILE = "omniroute";
|
||||
|
||||
/** 5dive's `claude` BYO endpoint is the Anthropic surface ROOT — strip a trailing /v1. */
|
||||
function stripToRoot(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s.slice(0, -3) : s;
|
||||
}
|
||||
|
||||
/** Resolve baseUrl (ROOT, no /v1) + apiKey from flags -> active context -> localhost. */
|
||||
export function resolveFivediveTarget(opts = {}) {
|
||||
let baseUrl;
|
||||
if (opts.remote) baseUrl = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
baseUrl = stripToRoot(
|
||||
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
|
||||
);
|
||||
} catch {
|
||||
/* no context configured */
|
||||
}
|
||||
if (!baseUrl)
|
||||
baseUrl = `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 {
|
||||
/* no context configured */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl, apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* 5dive refuses a base URL before storing it, and the rule is not the obvious
|
||||
* one: the agent's key rides this URL on every request, so https:// is required
|
||||
* unless the host is loopback. Reproduce the check here so the operator gets the
|
||||
* reason at the point of choosing, not a validation error three commands later.
|
||||
*/
|
||||
export function validateFivediveBaseUrl(rawUrl) {
|
||||
const url = String(rawUrl || "");
|
||||
if (!url) return { ok: false, reason: "A base URL is required." };
|
||||
if (url.startsWith("https://")) return { ok: true };
|
||||
if (!url.startsWith("http://")) {
|
||||
return { ok: false, reason: `Unsupported scheme in '${url}' (expected http:// or https://).` };
|
||||
}
|
||||
let host = url.slice("http://".length);
|
||||
host = host.split("/")[0].split("?")[0];
|
||||
host = host.startsWith("[") ? `${host.slice(0, host.indexOf("]"))}]` : host.split(":")[0];
|
||||
if (host === "127.0.0.1" || host === "localhost" || host === "[::1]") return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
`5dive accepts http:// only for a loopback host; '${host}' is off-box, so the agent's ` +
|
||||
`API key would travel in plaintext. Serve OmniRoute over https:// and pass ` +
|
||||
`--remote https://${host}...`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Argv for the profile write. The key is NOT here — it goes in on stdin. */
|
||||
export function buildFivediveAuthArgs({ baseUrl, profile, model, provider = "openai" }) {
|
||||
return [
|
||||
"agent",
|
||||
"auth",
|
||||
"set",
|
||||
"claude",
|
||||
`--provider=${provider}`,
|
||||
`--base-url=${baseUrl}`,
|
||||
"--api-key=-",
|
||||
`--auth-profile=${profile}`,
|
||||
`--model=${model}`,
|
||||
];
|
||||
}
|
||||
|
||||
/** Argv for one seat's runtime model pin, which outranks the profile's env defaults. */
|
||||
export function buildFivedivePinArgs(agent, model) {
|
||||
return ["agent", "config", agent, "set", `model=${model}`];
|
||||
}
|
||||
|
||||
/** Prepend sudo when the profile write needs root and we do not have it. */
|
||||
export function withPrivilege(bin, args, { isRoot, useSudo }) {
|
||||
if (isRoot || !useSudo) return [bin, args];
|
||||
return ["sudo", [bin, ...args]];
|
||||
}
|
||||
|
||||
function quote(arg) {
|
||||
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${String(arg).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/** Render argv the way an operator would type it. */
|
||||
export function renderCommand(bin, args) {
|
||||
return [bin, ...args].map(quote).join(" ");
|
||||
}
|
||||
|
||||
function run(bin, args, stdinPayload) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(bin, args, {
|
||||
// sudo reads its password straight from the tty, so stdin stays free for
|
||||
// the API key.
|
||||
stdio: [stdinPayload === undefined ? "inherit" : "pipe", "inherit", "inherit"],
|
||||
});
|
||||
child.on("error", (e) => resolve({ code: 1, error: e }));
|
||||
child.on("close", (code) => resolve({ code: code ?? 1 }));
|
||||
if (stdinPayload !== undefined && child.stdin) {
|
||||
child.stdin.end(stdinPayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function agentList(opts) {
|
||||
const raw = opts.agent ?? opts.agents ?? [];
|
||||
return (Array.isArray(raw) ? raw : [raw]).map((a) => String(a).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export async function runSetup5diveCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveFivediveTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const bin = opts.fivediveBin ?? opts["fivedive-bin"] ?? process.env.CLI_5DIVE_BIN ?? "5dive";
|
||||
const profile = String(opts.authProfile ?? opts["auth-profile"] ?? opts.name ?? DEFAULT_PROFILE);
|
||||
// NOT `opts.provider`: the `configure` picker uses that flag for the
|
||||
// OmniRoute model provider to filter on, and it reaches setup recipes
|
||||
// verbatim. The 5dive BYO id is its own flag.
|
||||
const provider = String(opts.byoProvider ?? opts["byo-provider"] ?? "openai");
|
||||
const agents = agentList(opts);
|
||||
|
||||
printHeading("OmniRoute -> 5dive (claude BYO endpoint)");
|
||||
printInfo(`Server: ${baseUrl}`);
|
||||
printInfo(`Profile: ${profile}`);
|
||||
|
||||
const urlCheck = validateFivediveBaseUrl(baseUrl);
|
||||
if (!urlCheck.ok) {
|
||||
printError(urlCheck.reason);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// 5dive needs one explicit model id: `openai` has no catalog row, so there
|
||||
// are no per-tier defaults to fall back to.
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
printInfo("A combo id works here too — that is how you get failover across providers.");
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model or combo id for the 5dive agents");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id> (5dive has no model auto-discovery here).");
|
||||
return 2;
|
||||
}
|
||||
if (!apiKey) {
|
||||
printError("An OmniRoute API key is required. Pass --api-key, or set OMNIROUTE_API_KEY.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const isRoot = typeof process.getuid === "function" ? process.getuid() === 0 : false;
|
||||
const useSudo = (opts.sudo ?? true) !== false;
|
||||
const authArgs = buildFivediveAuthArgs({ baseUrl, profile, model, provider });
|
||||
const [authBin, authArgv] = withPrivilege(bin, authArgs, { isRoot, useSudo });
|
||||
|
||||
if (dryRun) {
|
||||
printInfo("\n[dry-run] would run:");
|
||||
printInfo(` ${renderCommand(authBin, authArgv)}`);
|
||||
printInfo(" (the API key is written to that command's stdin, never to argv)");
|
||||
for (const agent of agents) {
|
||||
const [pinBin, pinArgv] = withPrivilege(bin, buildFivedivePinArgs(agent, model), {
|
||||
isRoot,
|
||||
useSudo,
|
||||
});
|
||||
printInfo(` ${renderCommand(pinBin, pinArgv)}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!isRoot && !useSudo) {
|
||||
printError(
|
||||
"Writing a 5dive auth profile needs root on the 5dive host. Re-run as root, drop --no-sudo, " +
|
||||
"or run this by hand:"
|
||||
);
|
||||
printInfo(` ${renderCommand(bin, authArgs)}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const authResult = await run(authBin, authArgv, apiKey);
|
||||
if (authResult.error?.code === "ENOENT") {
|
||||
printError(
|
||||
`Could not find the '${bin}' CLI on this machine. 5dive's verbs run ON the fleet host — ` +
|
||||
"run this there, or point at the binary with --fivedive-bin."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (authResult.code !== 0) {
|
||||
printError(`'${bin} agent auth set' exited ${authResult.code}.`);
|
||||
return authResult.code;
|
||||
}
|
||||
printSuccess(`Auth profile '${profile}' now points at ${baseUrl}`);
|
||||
|
||||
// The profile carries ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL, but each
|
||||
// seat's own runtime pin outranks it — a seat still pinned to a stock model id
|
||||
// fails its first turn with "There's an issue with the selected model".
|
||||
for (const agent of agents) {
|
||||
const [pinBin, pinArgv] = withPrivilege(bin, buildFivedivePinArgs(agent, model), {
|
||||
isRoot,
|
||||
useSudo,
|
||||
});
|
||||
const pinResult = await run(pinBin, pinArgv);
|
||||
if (pinResult.code !== 0) {
|
||||
printError(`Could not pin agent '${agent}' to '${model}' (exit ${pinResult.code}).`);
|
||||
return pinResult.code;
|
||||
}
|
||||
printSuccess(`Agent '${agent}' pinned to ${model}`);
|
||||
}
|
||||
|
||||
if (!agents.length) {
|
||||
printInfo("\nEach seat also carries its own runtime model pin, and it beats the profile:");
|
||||
printInfo(` ${renderCommand(bin, buildFivedivePinArgs("<agent>", model))}`);
|
||||
printInfo("Re-run with --agent <name> to have this command apply it for you.");
|
||||
}
|
||||
printInfo("\nBind a seat to the profile at creation time with:");
|
||||
printInfo(` ${renderCommand(bin, ["agent", "create", "<name>", `--auth-profile=${profile}`])}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetup5dive(program) {
|
||||
program
|
||||
.command("setup-5dive")
|
||||
.description(
|
||||
"Point a 5dive agent fleet's claude seats at OmniRoute (writes a 5dive auth profile)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. https://omniroute.example.com")
|
||||
.option("--context <name>", "Named local/remote context")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to the active context/env)")
|
||||
.option("--model <id>", "OmniRoute model or combo id the agents should use")
|
||||
.option("--byo-provider <id>", "5dive BYO provider id (default: openai)", "openai")
|
||||
.option("--auth-profile <name>", "5dive auth profile to write", DEFAULT_PROFILE)
|
||||
.option(
|
||||
"--agent <name>",
|
||||
"Also pin this agent's runtime model (repeatable)",
|
||||
(value, previous) => [...(previous || []), value],
|
||||
[]
|
||||
)
|
||||
.option("--fivedive-bin <path>", "Path to the 5dive binary (default: 5dive on PATH)")
|
||||
.option("--no-sudo", "Do not re-exec through sudo when not running as root")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print the commands without running them")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetup5diveCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -169,8 +169,7 @@ export async function runSetupClaudeCommand(opts = {}) {
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
const errorBody = await res.json();
|
||||
const serverMsg =
|
||||
errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
|
||||
const serverMsg = errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
|
||||
if (serverMsg) detail += ` — ${serverMsg}`;
|
||||
} catch {}
|
||||
throw new Error(detail);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { createPrompt, printHeading, printInfo, printSuccess } from "../io.mjs";
|
||||
import { openOmniRouteDb } from "../sqlite.mjs";
|
||||
@@ -16,7 +16,7 @@ import { t } from "../i18n.mjs";
|
||||
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
async function getListCliTools() {
|
||||
const { listCliTools } = await import(pathToFileURL(resolve(PROJECT_ROOT, "src/shared/constants/cliTools.ts")).href);
|
||||
const { listCliTools } = await import(`${PROJECT_ROOT}/src/shared/constants/cliTools.ts`);
|
||||
return listCliTools;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ function wantsProviderSetup(opts) {
|
||||
return opts.addProvider || Boolean(opts.provider) || Boolean(opts.apiKey);
|
||||
}
|
||||
|
||||
async function resolvePassword(opts, prompt, nonInteractive, settings) {
|
||||
if (opts.password !== undefined) return opts.password;
|
||||
if (!settings.password && process.env.INITIAL_PASSWORD) return process.env.INITIAL_PASSWORD;
|
||||
async function resolvePassword(opts, prompt, nonInteractive) {
|
||||
if (opts.password) return opts.password;
|
||||
if (process.env.INITIAL_PASSWORD) return process.env.INITIAL_PASSWORD;
|
||||
if (nonInteractive) return "";
|
||||
|
||||
const answer = await prompt.ask("Set an admin password now? [y/N]", "N");
|
||||
@@ -41,9 +41,9 @@ async function resolvePassword(opts, prompt, nonInteractive, settings) {
|
||||
}
|
||||
|
||||
async function setupPassword(db, opts, prompt, nonInteractive) {
|
||||
const settings = getSettings(db);
|
||||
const password = await resolvePassword(opts, prompt, nonInteractive, settings);
|
||||
const password = await resolvePassword(opts, prompt, nonInteractive);
|
||||
if (!password) {
|
||||
const settings = getSettings(db);
|
||||
if (!settings.password) {
|
||||
updateSettings(db, { requireLogin: false });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { mcpCallTool } from "../mcpClient.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
@@ -107,7 +106,14 @@ export async function runSkillsInstall(opts, cmd) {
|
||||
}
|
||||
|
||||
export async function runSkillsEnable(id, opts, cmd) {
|
||||
await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: true });
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: true } },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`Enabled: ${id}\n`);
|
||||
}
|
||||
|
||||
@@ -116,7 +122,14 @@ export async function runSkillsDisable(id, opts, cmd) {
|
||||
const ok = await confirm(`Disable ${id}?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: false });
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: false } },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(`Disabled: ${id}\n`);
|
||||
}
|
||||
|
||||
@@ -140,11 +153,16 @@ export async function runSkillsExecute(id, opts, cmd) {
|
||||
: opts.inputFile
|
||||
? JSON.parse(readFileSync(opts.inputFile, "utf8"))
|
||||
: {};
|
||||
const data = await mcpCallTool(
|
||||
"omniroute_skills_execute",
|
||||
{ skillId: id, input },
|
||||
{ timeout: opts.timeout ?? 30000 },
|
||||
);
|
||||
const res = await apiFetch("/api/mcp/tools/call", {
|
||||
method: "POST",
|
||||
body: { name: "omniroute_skills_execute", arguments: { skillId: id, input } },
|
||||
timeout: opts.timeout ?? 30000,
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = await res.json();
|
||||
emit(data, globalOpts);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export function registerTunnel(program) {
|
||||
});
|
||||
|
||||
tunnel
|
||||
.command("create")
|
||||
.command("create [type]")
|
||||
.description(t("tunnel.createDescription"))
|
||||
.addArgument(
|
||||
new Argument("[type]", "Tunnel type").choices(VALID_TUNNEL_TYPES).default("cloudflare")
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { printHeading, printInfo, printSuccess, printError, printWarning } from "../io.mjs";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { npmBin, npmExecOptions } from "../npm-exec.mjs";
|
||||
import { readPidFile, isPidRunning } from "../utils/pid.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -33,13 +31,9 @@ export async function getCurrentVersion() {
|
||||
// they were already on the latest version (#4376). `execFn` is injectable for tests.
|
||||
export async function getLatestVersion(execFn = execFileAsync) {
|
||||
try {
|
||||
// argv is all literals, so enabling the shell on win32 cannot splice a
|
||||
// runtime value into the command line (Hard Rule #13).
|
||||
const { stdout } = await execFn(
|
||||
npmBin(),
|
||||
["view", "omniroute", "version", "--prefer-online"],
|
||||
npmExecOptions(process.platform, { timeoutMs: 15000 })
|
||||
);
|
||||
const { stdout } = await execFn("npm", ["view", "omniroute", "version", "--prefer-online"], {
|
||||
timeout: 15000,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
@@ -80,39 +74,6 @@ export async function createBackup() {
|
||||
}
|
||||
}
|
||||
|
||||
// #11885: `--apply` installs the new files (npm install -g) and re-reads
|
||||
// package.json from disk to confirm it, but a long-lived server process keeps
|
||||
// serving whatever it loaded at its last start — Node caches a `require()`d
|
||||
// package.json per resolved path for the life of the process. A later
|
||||
// `omniroute update` then correctly reports "already up to date" (the files
|
||||
// ARE current) while the running server is still stale, matching the reported
|
||||
// symptom. `--apply` never restarted anything and its success message ("Run
|
||||
// `omniroute --version` to verify.") implied the update was already live.
|
||||
//
|
||||
// `restart.mjs`'s `runRestartCommand()` stops then re-spawns the server in the
|
||||
// foreground (via `serve.mjs::runServe`), which can block the calling terminal
|
||||
// and is a materially bigger behavior change than this fix warrants to invoke
|
||||
// unconditionally and unattended from `--apply`. Instead, detect whether a
|
||||
// CLI-managed server is currently running (the same PID file `stop.mjs`/
|
||||
// `restart.mjs` already trust) and print an explicit, prominent instruction —
|
||||
// honest about what did and didn't happen — rather than silently assuming.
|
||||
export async function isServerProcessRunning(deps = { readPidFile, isPidRunning }) {
|
||||
const pid = deps.readPidFile("server");
|
||||
return Boolean(pid && deps.isPidRunning(pid));
|
||||
}
|
||||
|
||||
export async function printPostApplyGuidance(latest, deps = { readPidFile, isPidRunning }) {
|
||||
const running = await isServerProcessRunning(deps);
|
||||
if (running) {
|
||||
printWarning(`Files updated to ${latest}, but the running server is still on the old version.`);
|
||||
printInfo(" Run `omniroute restart` now to apply this update.");
|
||||
} else {
|
||||
printInfo(`No running OmniRoute server was detected via the CLI's PID file.`);
|
||||
printInfo(` Start it with \`omniroute serve\` (or restart your existing process) to run ${latest}.`);
|
||||
}
|
||||
printInfo("`omniroute --version` will keep reporting the old version until the process restarts.");
|
||||
}
|
||||
|
||||
export function registerUpdate(program) {
|
||||
program
|
||||
.command("update")
|
||||
@@ -153,11 +114,9 @@ export async function runUpdateCommand(opts = {}) {
|
||||
|
||||
if (showChangelog) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
npmBin(),
|
||||
["view", "omniroute", "changelog"],
|
||||
npmExecOptions(process.platform, { timeoutMs: 15000 })
|
||||
);
|
||||
const { stdout } = await execFileAsync("npm", ["view", "omniroute", "changelog"], {
|
||||
timeout: 10000,
|
||||
});
|
||||
if (stdout.trim()) {
|
||||
console.log(stdout.trim());
|
||||
} else {
|
||||
@@ -244,8 +203,8 @@ export async function runUpdateCommand(opts = {}) {
|
||||
console.log(" or reorder PATH so the global bin comes first.");
|
||||
return 1;
|
||||
}
|
||||
printSuccess(`Installed omniroute@${latest} to disk.`);
|
||||
await printPostApplyGuidance(latest);
|
||||
printSuccess(`Updated to version ${latest}`);
|
||||
printInfo("Run `omniroute --version` to verify.");
|
||||
return 0;
|
||||
} catch (err) {
|
||||
printError(`Update failed: ${err.message}`);
|
||||
|
||||
@@ -5,25 +5,6 @@ import { fileURLToPath } from "node:url";
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const LOCALES_DIR = join(__dirname, "locales");
|
||||
const FALLBACK_LOCALE = "en";
|
||||
const I18N_CONFIG_PATH = join(__dirname, "..", "..", "config", "i18n.json");
|
||||
let aliasMap = null; // lower-case tag → canonical locale code
|
||||
|
||||
function loadAliasMap() {
|
||||
if (aliasMap) return aliasMap;
|
||||
aliasMap = new Map();
|
||||
try {
|
||||
const { locales } = JSON.parse(readFileSync(I18N_CONFIG_PATH, "utf8"));
|
||||
for (const entry of locales) {
|
||||
aliasMap.set(entry.code.toLowerCase(), entry.code);
|
||||
for (const alias of entry.aliases || []) {
|
||||
aliasMap.set(String(alias).toLowerCase(), entry.code);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// config absent (trimmed package): keep file-based detection only
|
||||
}
|
||||
return aliasMap;
|
||||
}
|
||||
|
||||
const cache = new Map();
|
||||
let activeLocale = null;
|
||||
@@ -43,16 +24,8 @@ function normalize(raw) {
|
||||
const stripped = String(raw).split(".")[0].replaceAll("_", "-");
|
||||
if (!stripped || !/^[a-zA-Z0-9-]+$/.test(stripped)) return FALLBACK_LOCALE;
|
||||
if (hasCatalog(stripped)) return stripped;
|
||||
const lower = stripped.toLowerCase();
|
||||
const base = lower.split("-")[0];
|
||||
const aliases = loadAliasMap();
|
||||
const viaAlias = aliases.get(lower) ?? aliases.get(base);
|
||||
if (viaAlias && hasCatalog(viaAlias)) return viaAlias;
|
||||
const base = stripped.split("-")[0];
|
||||
if (hasCatalog(base)) return base;
|
||||
const regional = [...new Set(aliases.values())].find(
|
||||
(code) => code.includes("-") && code.toLowerCase().split("-")[0] === base
|
||||
);
|
||||
if (regional && hasCatalog(regional)) return regional;
|
||||
return FALLBACK_LOCALE;
|
||||
}
|
||||
|
||||
@@ -130,5 +103,4 @@ export function resetForTests() {
|
||||
cache.clear();
|
||||
activeLocale = null;
|
||||
fallbackCatalog = null;
|
||||
aliasMap = null;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,3 @@ export function printInfo(message) {
|
||||
export function printError(message) {
|
||||
console.log(`\x1b[31m✖ ${message}\x1b[0m`);
|
||||
}
|
||||
|
||||
export function printWarning(message) {
|
||||
console.log(`\x1b[33m⚠ ${message}\x1b[0m`);
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
"log": "Show server logs inline",
|
||||
"no_recovery": "Disable auto-restart on crash (debugging mode)",
|
||||
"max_restarts": "Max crash restarts within 30s before giving up (default: 2)",
|
||||
"tray": "Start in the system tray (desktop only, opt-in)",
|
||||
"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)"
|
||||
|
||||
5
bin/cli/locales/in.json
Normal file
5
bin/cli/locales/in.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"redis": {
|
||||
"description": "Luncurkan kontainer Redis lokal dengan satu klik (Podman atau Docker) untuk caching dan pelacakan kuota OmniRoute"
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,7 @@
|
||||
"testFailed": "Teste do provedor falhou: {error}",
|
||||
"loginEnabled": "Login: habilitado (senha atualizada)",
|
||||
"loginDisabled": "Login: desabilitado",
|
||||
"providerInfo": "Provedor: {info}",
|
||||
"opencode": "Instala e configura o plugin @omniroute/opencode-plugin incluído para o OpenCode"
|
||||
"providerInfo": "Provedor: {info}"
|
||||
},
|
||||
"doctor": {
|
||||
"title": "OmniRoute Doctor",
|
||||
@@ -255,9 +254,7 @@
|
||||
"no_recovery": "Desabilitar reinício automático em crash (modo debug)",
|
||||
"max_restarts": "Máximo de reinícios em 30s antes de desistir (padrão: 2)",
|
||||
"tray": "Mostrar ícone na bandeja do sistema (apenas desktop, opt-in)",
|
||||
"no_tray": "Desabilitar ícone na bandeja do sistema",
|
||||
"tls_cert": "Caminho para um certificado TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "Caminho para a chave privada TLS (PEM) para servir HTTPS (também OMNIROUTE_TLS_KEY)"
|
||||
"no_tray": "Desabilitar ícone na bandeja do sistema"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
|
||||
@@ -38,8 +38,7 @@
|
||||
"testFailed": "提供者测试失败:{error}",
|
||||
"loginEnabled": "登录:已启用(密码已更新)",
|
||||
"loginDisabled": "登录:已禁用",
|
||||
"providerInfo": "提供者:{info}",
|
||||
"opencode": "安装并配置随附的 @omniroute/opencode-plugin 以用于 OpenCode"
|
||||
"providerInfo": "提供者:{info}"
|
||||
},
|
||||
"doctor": {
|
||||
"title": "OmniRoute 诊断",
|
||||
@@ -253,9 +252,7 @@
|
||||
"no_recovery": "禁用崩溃自动重启(调试模式)",
|
||||
"max_restarts": "30 秒内的最大崩溃重启次数(默认:2)",
|
||||
"tray": "显示系统托盘图标(仅桌面,选择加入)",
|
||||
"no_tray": "禁用系统托盘图标",
|
||||
"tls_cert": "用于提供 HTTPS 服务的 TLS 证书(PEM)路径(也可用 OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "用于提供 HTTPS 服务的 TLS 私钥(PEM)路径(也可用 OMNIROUTE_TLS_KEY)"
|
||||
"no_tray": "禁用系统托盘图标"
|
||||
},
|
||||
"backup": {
|
||||
"title": "备份",
|
||||
@@ -1261,69 +1258,5 @@
|
||||
"search": "搜索 npm 注册表中的可用插件",
|
||||
"update": "更新已安装的插件",
|
||||
"scaffold": "搭建新的插件模板"
|
||||
},
|
||||
"authExport": {
|
||||
"description": "导出已解密的提供者凭据(仅限本地,明文输出)",
|
||||
"idOpt": "仅导出与此 id/名称/提供者匹配的连接",
|
||||
"formatOpt": "输出格式:json 或 env",
|
||||
"outOpt": "将输出写入文件而非标准输出(以 0600 权限写入)",
|
||||
"forceOpt": "确认你了解此操作会打印/写入明文密钥",
|
||||
"warning": "⚠ 此操作会打印/写入已解密的明文 API 密钥和 OAuth 令牌。请确保你的屏幕、shell 历史记录以及任何输出文件保持私密。",
|
||||
"confirmHeading": "⚠ 警告:此操作会以明文导出已解密的提供者凭据",
|
||||
"confirmBody": "此命令会为所选连接解密并打印/写入 apiKey、accessToken、refreshToken 和\nidToken。请将输出视为机密。",
|
||||
"confirmFooter": "如需确认,请运行:\n omniroute auth export --force",
|
||||
"missingKey": "导出凭据需要 STORAGE_ENCRYPTION_KEY。",
|
||||
"notFound": "未找到连接:{id}",
|
||||
"invalidFormat": "无效格式:{format}。请使用 json 或 env。"
|
||||
},
|
||||
"radar": {
|
||||
"description": "检查并同步本地 Radar 目录订阅源",
|
||||
"status": "显示本地 Radar 设置和订阅源缓存状态",
|
||||
"sync": "通过本地服务器同步目录、推荐、优惠和 Intel"
|
||||
},
|
||||
"launch": {
|
||||
"description": "启动指向 OmniRoute 的 Claude Code(本地或远程,使用 --profile)",
|
||||
"token": "Claude 客户端应发送的令牌(ANTHROPIC_AUTH_TOKEN)",
|
||||
"notRunning": "无法在 {port} 访问 OmniRoute。请使用 “omniroute serve” 启动它。",
|
||||
"notFound": "在 PATH 中未找到 “claude” CLI。"
|
||||
},
|
||||
"run": {
|
||||
"description": "通过 OmniRoute 启动受支持的 CLI 目标"
|
||||
},
|
||||
"setupClaude": {
|
||||
"description": "从 OmniRoute 模型目录生成 ~/.claude/profiles 的 Claude Code 配置文件"
|
||||
},
|
||||
"connect": {
|
||||
"description": "连接到远程 OmniRoute 服务器并进入远程模式"
|
||||
},
|
||||
"tokens": {
|
||||
"description": "管理限定范围的 CLI 访问令牌(远程模式)"
|
||||
},
|
||||
"configure": {
|
||||
"description": "从活动服务器选择提供者+模型并配置受支持的本地 CLI"
|
||||
},
|
||||
"launchCodex": {
|
||||
"description": "启动指向 OmniRoute 的 Codex CLI(本地或远程 VPS)"
|
||||
},
|
||||
"setupCodex": {
|
||||
"description": "从 OmniRoute 实时模型目录生成 ~/.codex 配置文件"
|
||||
},
|
||||
"packs": {
|
||||
"description": "管理可选的运行时包(ML / 浏览器自动化)",
|
||||
"listDescription": "列出可选包及其安装状态",
|
||||
"installDescription": "将可选包安装到 DATA_DIR",
|
||||
"verifyDescription": "根据随附的校验和索引验证已安装的包",
|
||||
"removeDescription": "移除已安装的可选包",
|
||||
"sourceOpt": "存放包负载和包索引的目录",
|
||||
"warnNoIndex": "未找到 optional-packs.index.json —— 此检出无法进行安装/验证(桌面捆绑包会附带它)",
|
||||
"errUnknown": "未知的包:{name}",
|
||||
"errNoIndex": "未找到包索引;请通过 --source <dir> 传入存放包负载的目录(桌面捆绑包会将其附带在应用旁)",
|
||||
"installed": "包 “{name}” 已安装并在 {dir} 验证通过",
|
||||
"restartHint": "请重启 OmniRoute 服务器(或桌面应用),以便运行时加载该包",
|
||||
"removed": "包 “{name}” 已移除",
|
||||
"notInstalled": "包 “{name}” 未安装",
|
||||
"verifyOk": "所有已安装的包均已验证通过",
|
||||
"verifyFailed": "{count} 个包验证失败",
|
||||
"noneInstalled": "未安装可选包"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,7 @@
|
||||
"testFailed": "提供者測試失敗:{error}",
|
||||
"loginEnabled": "登入:已啟用(密碼已更新)",
|
||||
"loginDisabled": "登入:已停用",
|
||||
"providerInfo": "提供者:{info}",
|
||||
"opencode": "安裝並配置隨附的 @omniroute/opencode-plugin 以用於 OpenCode"
|
||||
"providerInfo": "提供者:{info}"
|
||||
},
|
||||
"doctor": {
|
||||
"title": "OmniRoute 診斷",
|
||||
@@ -253,9 +252,7 @@
|
||||
"no_recovery": "停用崩潰自動重啟(除錯模式)",
|
||||
"max_restarts": "30 秒內的最大崩潰重啟次數(預設:2)",
|
||||
"tray": "顯示系統托盤圖示(僅桌面,選擇加入)",
|
||||
"no_tray": "停用系統托盤圖示",
|
||||
"tls_cert": "用於提供 HTTPS 服務的 TLS 憑證(PEM)路徑(也可用 OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "用於提供 HTTPS 服務的 TLS 私鑰(PEM)路徑(也可用 OMNIROUTE_TLS_KEY)"
|
||||
"no_tray": "停用系統托盤圖示"
|
||||
},
|
||||
"backup": {
|
||||
"title": "備份",
|
||||
@@ -1261,69 +1258,5 @@
|
||||
"search": "搜尋 npm 登錄檔中的可用外掛",
|
||||
"update": "更新已安裝的外掛",
|
||||
"scaffold": "搭建新的外掛模板"
|
||||
},
|
||||
"authExport": {
|
||||
"description": "匯出已解密的提供者憑據(僅限本機,明文輸出)",
|
||||
"idOpt": "僅匯出與此 id/名稱/提供者相符的連線",
|
||||
"formatOpt": "輸出格式:json 或 env",
|
||||
"outOpt": "將輸出寫入檔案而非標準輸出(以 0600 權限寫入)",
|
||||
"forceOpt": "確認你了解此操作會列印/寫入明文密鑰",
|
||||
"warning": "⚠ 此操作會列印/寫入已解密的明文 API 金鑰和 OAuth 令牌。請確保你的螢幕、shell 歷史記錄以及任何輸出檔案保持私密。",
|
||||
"confirmHeading": "⚠ 警告:此操作會以明文匯出已解密的提供者憑據",
|
||||
"confirmBody": "此命令會為所選連線解密並列印/寫入 apiKey、accessToken、refreshToken 和\nidToken。請將輸出視為機密。",
|
||||
"confirmFooter": "如需確認,請執行:\n omniroute auth export --force",
|
||||
"missingKey": "匯出憑據需要 STORAGE_ENCRYPTION_KEY。",
|
||||
"notFound": "找不到連線:{id}",
|
||||
"invalidFormat": "無效格式:{format}。請使用 json 或 env。"
|
||||
},
|
||||
"radar": {
|
||||
"description": "檢查並同步本機 Radar 目錄訂閱來源",
|
||||
"status": "顯示本機 Radar 設定和訂閱來源快取狀態",
|
||||
"sync": "透過本機伺服器同步目錄、推薦、優惠和 Intel"
|
||||
},
|
||||
"launch": {
|
||||
"description": "啟動指向 OmniRoute 的 Claude Code(本機或遠端,使用 --profile)",
|
||||
"token": "Claude 用戶端應傳送的令牌(ANTHROPIC_AUTH_TOKEN)",
|
||||
"notRunning": "無法在 {port} 存取 OmniRoute。請使用「omniroute serve」啟動它。",
|
||||
"notFound": "在 PATH 中找不到「claude」CLI。"
|
||||
},
|
||||
"run": {
|
||||
"description": "透過 OmniRoute 啟動受支援的 CLI 目標"
|
||||
},
|
||||
"setupClaude": {
|
||||
"description": "從 OmniRoute 模型目錄產生 ~/.claude/profiles 的 Claude Code 配置檔"
|
||||
},
|
||||
"connect": {
|
||||
"description": "連線到遠端 OmniRoute 伺服器並進入遠端模式"
|
||||
},
|
||||
"tokens": {
|
||||
"description": "管理限定範圍的 CLI 存取令牌(遠端模式)"
|
||||
},
|
||||
"configure": {
|
||||
"description": "從使用中的伺服器選擇提供者+模型並配置受支援的本機 CLI"
|
||||
},
|
||||
"launchCodex": {
|
||||
"description": "啟動指向 OmniRoute 的 Codex CLI(本機或遠端 VPS)"
|
||||
},
|
||||
"setupCodex": {
|
||||
"description": "從 OmniRoute 即時模型目錄產生 ~/.codex 配置檔"
|
||||
},
|
||||
"packs": {
|
||||
"description": "管理可選的執行階段套件(ML / 瀏覽器自動化)",
|
||||
"listDescription": "列出可選套件及其安裝狀態",
|
||||
"installDescription": "將可選套件安裝到 DATA_DIR",
|
||||
"verifyDescription": "根據隨附的總和檢查碼索引驗證已安裝的套件",
|
||||
"removeDescription": "移除已安裝的可選套件",
|
||||
"sourceOpt": "存放套件負載和套件索引的目錄",
|
||||
"warnNoIndex": "找不到 optional-packs.index.json —— 此檢出無法進行安裝/驗證(桌面套件會隨附它)",
|
||||
"errUnknown": "未知的套件:{name}",
|
||||
"errNoIndex": "找不到套件索引;請透過 --source <dir> 傳入存放套件負載的目錄(桌面套件會將其隨附在應用程式旁)",
|
||||
"installed": "套件「{name}」已安裝並在 {dir} 驗證通過",
|
||||
"restartHint": "請重新啟動 OmniRoute 伺服器(或桌面應用程式),以便執行階段載入該套件",
|
||||
"removed": "套件「{name}」已移除",
|
||||
"notInstalled": "套件「{name}」未安裝",
|
||||
"verifyOk": "所有已安裝的套件均已驗證通過",
|
||||
"verifyFailed": "{count} 個套件驗證失敗",
|
||||
"noneInstalled": "未安裝可選套件"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Shared MCP JSON-RPC client for CLI commands.
|
||||
*
|
||||
* The server exposes MCP through /api/mcp/stream (Streamable HTTP transport).
|
||||
* Calling a tool requires:
|
||||
* 1. POST initialize → get Mcp-Session-Id response header
|
||||
* 2. POST tools/call with that session header
|
||||
*
|
||||
* Older CLI paths POSTed { name, arguments } to /api/mcp/tools/call, which is
|
||||
* not a registered route, so every MCP-backed command was broken.
|
||||
*
|
||||
* These functions route through apiFetch so CLI auth, remote contexts and
|
||||
* timeouts are handled the same way as every other management API call.
|
||||
*/
|
||||
import { apiFetch } from "./api.mjs";
|
||||
|
||||
function mcpError(message, status) {
|
||||
const err = new Error(message);
|
||||
if (status) err.status = status;
|
||||
return err;
|
||||
}
|
||||
|
||||
async function callMcpEndpoint(payload, { timeout, stream }) {
|
||||
const res = await apiFetch("/api/mcp/stream", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
timeout,
|
||||
acceptNotOk: true,
|
||||
headers: stream ? { Accept: "text/event-stream" } : {},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw mcpError(
|
||||
`${payload.method} ${payload.id}: HTTP ${res.status}${text ? ` — ${text}` : ""}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an MCP tool over /api/mcp/stream.
|
||||
*
|
||||
* Non-stream: returns the JSON-RPC result payload.
|
||||
* Stream: writes SSE `data:` chunks to stdout and returns null on success.
|
||||
*/
|
||||
export async function mcpCallTool(name, args = {}, options = {}) {
|
||||
const { timeout, scope } = options;
|
||||
const scopeHeader = scope?.length ? { "X-MCP-Scopes": scope.join(",") } : {};
|
||||
|
||||
const initRes = await callMcpEndpoint(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "omniroute-cli", version: "1.0" },
|
||||
},
|
||||
},
|
||||
{ timeout, stream: options.stream },
|
||||
);
|
||||
|
||||
const sessionId = initRes.headers.get("mcp-session-id");
|
||||
if (!sessionId) {
|
||||
throw mcpError("MCP initialize failed: no Mcp-Session-Id in response", 500);
|
||||
}
|
||||
|
||||
const callRes = await callMcpEndpoint(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: { name, arguments: args },
|
||||
},
|
||||
{ timeout, stream: options.stream },
|
||||
);
|
||||
|
||||
if (options.stream) {
|
||||
return consumeSse(callRes.body, options.onChunk);
|
||||
}
|
||||
|
||||
const data = await callRes.json();
|
||||
if (data.error) {
|
||||
const err = mcpError(`MCP error: ${data.error.message || JSON.stringify(data.error)}`);
|
||||
err.code = data.error.code;
|
||||
throw err;
|
||||
}
|
||||
if (data.result?.isError) {
|
||||
const msg = data.result?.content?.[0]?.text || "unknown tool error";
|
||||
throw mcpError(`MCP error: ${msg}`, 500);
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
|
||||
async function consumeSse(body, onChunk) {
|
||||
if (!body) throw mcpError("MCP stream returned no body", 500);
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
const flushLines = () => {
|
||||
let idx;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 1);
|
||||
if (line.startsWith("data: ")) {
|
||||
const raw = line.slice(6).trim();
|
||||
if (raw && raw !== "[DONE]") (onChunk ?? writeStdout)(raw);
|
||||
}
|
||||
}
|
||||
};
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
flushLines();
|
||||
}
|
||||
buf += decoder.decode();
|
||||
flushLines();
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeStdout(raw) {
|
||||
process.stdout.write(raw + "\n");
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// Spawning npm from the CLI, on every platform.
|
||||
//
|
||||
// On Windows npm is `npm.cmd`, a batch wrapper. Node ≥ 24 refuses to spawn a
|
||||
// `.cmd` without a shell (nodejs/node#52554), and a bare `npm` can additionally
|
||||
// resolve to an extensionless shim that `CreateProcess` cannot execute — so the
|
||||
// call fails with `EINVAL` or `ENOENT` while npm works fine in the same terminal.
|
||||
// `src/lib/services/installers/utils.ts` already solves this for the server; this
|
||||
// is the same rule for the `bin/cli` entry points, which cannot import TypeScript.
|
||||
//
|
||||
// SECURITY (Hard Rule #13): enabling the shell means the SHELL splits the command
|
||||
// line, not `execFile`. Every argv element passed alongside these options must be
|
||||
// a literal — never a runtime value — or it must be validated first. Callers that
|
||||
// need to pass a user-supplied name have to guard it themselves.
|
||||
|
||||
/** The npm binary to spawn on this platform. */
|
||||
export function npmBin(platform = process.platform) {
|
||||
const isBun = Boolean(process.versions.bun);
|
||||
if (platform === "win32") return isBun ? "bun.exe" : "npm.cmd";
|
||||
return isBun ? "bun" : "npm";
|
||||
}
|
||||
|
||||
/**
|
||||
* `execFile` / `spawnSync` options for an npm call.
|
||||
*
|
||||
* @param {NodeJS.Platform} platform
|
||||
* @param {{ timeoutMs?: number, stdio?: string }} [options]
|
||||
*/
|
||||
export function npmExecOptions(platform = process.platform, options = {}) {
|
||||
const base = {};
|
||||
if (options.timeoutMs !== undefined) base.timeout = options.timeoutMs;
|
||||
if (options.stdio !== undefined) base.stdio = options.stdio;
|
||||
if (platform !== "win32") return { ...base, shell: false };
|
||||
return { ...base, shell: true, windowsHide: true };
|
||||
}
|
||||
@@ -10,10 +10,6 @@ const PROVIDER_TEST_CONFIGS = {
|
||||
format: "openai",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
model: "openai/gpt-4o-mini",
|
||||
// #11226: /models is public on OpenRouter (200 with any or no key) — probe the
|
||||
// authenticated key-info endpoint instead so a bad key fails the test here
|
||||
// instead of on the first real chat request.
|
||||
keyCheckPath: "/auth/key",
|
||||
},
|
||||
groq: {
|
||||
format: "openai",
|
||||
@@ -105,19 +101,13 @@ async function testOpenAILikeProvider(input, config) {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// Providers whose /models endpoint is public (e.g. OpenRouter) declare a
|
||||
// keyCheckPath pointing at an authenticated endpoint so the probe actually
|
||||
// exercises the key instead of the public catalog.
|
||||
const probeRes = await fetchWithTimeout(
|
||||
joinUrl(config.baseUrl, config.keyCheckPath || "/models"),
|
||||
{
|
||||
method: "GET",
|
||||
headers,
|
||||
}
|
||||
);
|
||||
const modelsRes = await fetchWithTimeout(joinUrl(config.baseUrl, "/models"), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (probeRes.ok || probeRes.status === 401 || probeRes.status === 403) {
|
||||
return classifyResponse(probeRes);
|
||||
if (modelsRes.ok || modelsRes.status === 401 || modelsRes.status === 403) {
|
||||
return classifyResponse(modelsRes);
|
||||
}
|
||||
|
||||
const chatRes = await fetchWithTimeout(joinUrl(config.baseUrl, "/chat/completions"), {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { apiFetch, isServerUp } from "./api.mjs";
|
||||
|
||||
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
// Dynamic `import()` resolves its specifier as a URL, not as a filesystem path.
|
||||
// On Windows an absolute path starts with a drive letter, which the ESM loader
|
||||
// reads as the unsupported URL scheme `e:` and rejects. Pass a file:// URL.
|
||||
const projectFileUrl = (relPath) => pathToFileURL(resolve(PROJECT_ROOT, relPath)).href;
|
||||
|
||||
export class ServerOfflineError extends Error {
|
||||
constructor(message = "Server is offline and operation requires HTTP runtime") {
|
||||
super(message);
|
||||
@@ -27,8 +22,8 @@ function makeHttpContext(opts) {
|
||||
|
||||
async function importDbModules() {
|
||||
const [combos, recovery] = await Promise.all([
|
||||
import(projectFileUrl("src/lib/db/combos.ts")),
|
||||
import(projectFileUrl("src/lib/db/recovery.ts")),
|
||||
import(`${PROJECT_ROOT}/src/lib/db/combos.ts`),
|
||||
import(`${PROJECT_ROOT}/src/lib/db/recovery.ts`),
|
||||
]);
|
||||
return { combos, recovery };
|
||||
}
|
||||
|
||||
@@ -114,30 +114,30 @@ 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.
|
||||
// npm 12+ defaults `allowScripts` to off, silently skipping lifecycle/install
|
||||
// scripts (e.g. better-sqlite3's node-gyp/prebuild-install rebuild) unless the
|
||||
// package has a matching `allowScripts` entry — and still exits 0, masking the
|
||||
// failure (#10713). The runtime dir is a CLI-owned, non-user package.json, so
|
||||
// explicitly allowing scripts for the packages we are installing here is safe.
|
||||
const npmArgs = [
|
||||
"install",
|
||||
...pkgs,
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--prefer-online",
|
||||
"--save-exact",
|
||||
...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
|
||||
];
|
||||
// On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly
|
||||
// so we never set shell:true (which would propagate env and enable injection).
|
||||
const isWin = platform() === "win32";
|
||||
const isBun = Boolean(process.versions.bun);
|
||||
|
||||
let exe, args, displayCmd;
|
||||
if (isBun) {
|
||||
const bunArgs = ["add", ...pkgs, "--trust"];
|
||||
[exe, args] = isWin ? ["cmd.exe", ["/c", "bun", ...bunArgs]] : ["bun", bunArgs];
|
||||
displayCmd = `bun ${bunArgs.join(" ")}`;
|
||||
} else {
|
||||
const npmArgs = [
|
||||
"install",
|
||||
...pkgs,
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--prefer-online",
|
||||
"--save-exact",
|
||||
...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
|
||||
];
|
||||
[exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
|
||||
displayCmd = `npm ${npmArgs.join(" ")}`;
|
||||
}
|
||||
|
||||
const [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
|
||||
if (!opts.silent) {
|
||||
process.stdout.write(`[omniroute][runtime] ${displayCmd}\n`);
|
||||
process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`);
|
||||
}
|
||||
const res = spawnSync(exe, args, {
|
||||
cwd,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs";
|
||||
import {
|
||||
RESTART_RESET_MS,
|
||||
@@ -18,24 +17,6 @@ import {
|
||||
|
||||
const CRASH_LOG_LINES = 50;
|
||||
|
||||
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
// Bun needs the Node-compat polyfill preloaded (#9761). The file ships at the
|
||||
// package root via package.json "files" (see scripts/build/pack-artifact-policy.ts)
|
||||
// and is never copied into dist/, so the path must resolve against the package
|
||||
// root — resolving it next to the server bundle fails with "preload not found" (#11980).
|
||||
export const BUN_PRELOAD_PATH = join(PACKAGE_ROOT, "open-sse", "utils", "setupPolyfill.ts");
|
||||
|
||||
/**
|
||||
* Argument vector for the server child. Kept pure so tests can assert on it
|
||||
* directly: the bare `import { spawn }` above cannot be intercepted without
|
||||
* --experimental-test-module-mocks (same seam as #8131).
|
||||
*/
|
||||
export function buildServerSpawnArgs(serverPath, memoryLimit, env = process.env) {
|
||||
return process.versions.bun
|
||||
? ["--preload", BUN_PRELOAD_PATH, serverPath]
|
||||
: buildNodeRuntimeArgs(env, memoryLimit, serverPath);
|
||||
}
|
||||
|
||||
export class ServerSupervisor {
|
||||
constructor({
|
||||
serverPath,
|
||||
@@ -74,11 +55,21 @@ export class ServerSupervisor {
|
||||
// Node args come from buildNodeRuntimeArgs (#9209 IPv4-first DNS + #5238
|
||||
// heap flag handling); the Bun branch keeps #9761's polyfill preload —
|
||||
// Bun does not accept the Node-only flags.
|
||||
this.child = spawn(process.execPath, buildServerSpawnArgs(this.serverPath, this.memoryLimit), {
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
this.child = spawn(
|
||||
process.execPath,
|
||||
process.versions.bun
|
||||
? [
|
||||
"--preload",
|
||||
join(dirname(this.serverPath), "open-sse/utils/setupPolyfill.ts"),
|
||||
this.serverPath,
|
||||
]
|
||||
: buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath),
|
||||
{
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
|
||||
writePidFile("server", this.child.pid);
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ import { pathToFileURL } from "node:url";
|
||||
import { validateBinaryMagic, platformBinaryLabel } from "./magicBytes.mjs";
|
||||
|
||||
const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime");
|
||||
// Exported so the packaging coherence guard (tests/unit/pack-boot-runtime-paths.test.ts)
|
||||
// can assert this stays on the same major as optionalDependencies.better-sqlite3 (#11242).
|
||||
export const BETTER_SQLITE3_VERSION = "better-sqlite3@^13.0.2";
|
||||
const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.10.1";
|
||||
|
||||
let resolvedCached = null;
|
||||
|
||||
@@ -26,17 +24,6 @@ let resolvedCached = null;
|
||||
export async function loadSqliteRuntime() {
|
||||
if (resolvedCached) return resolvedCached;
|
||||
|
||||
if (process.versions.bun) {
|
||||
try {
|
||||
const bunSqlite = await import("bun:sqlite");
|
||||
resolvedCached = {
|
||||
driver: { kind: "bun-sqlite", Database: bunSqlite.Database },
|
||||
source: "bun-sqlite",
|
||||
};
|
||||
return resolvedCached;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const bundled = await tryLoadBundled();
|
||||
if (bundled) {
|
||||
resolvedCached = { driver: bundled, source: "bundled" };
|
||||
|
||||
@@ -2,7 +2,6 @@ import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime");
|
||||
// systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime,
|
||||
@@ -17,16 +16,6 @@ export const SYSTRAY_PACKAGE = "systray2";
|
||||
export const SYSTRAY_VERSION = "2.1.4";
|
||||
const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`;
|
||||
|
||||
// Dynamic `import()` resolves its specifier as a URL, not a filesystem path.
|
||||
// On Windows the lazily-installed systray2 lives at an absolute path whose
|
||||
// leading drive letter the ESM loader parses as an unsupported URL scheme
|
||||
// (e.g. `c:`) and rejects. Build a file:// URL so the tray import works on
|
||||
// Windows too. Same defect fixed for the CLI db-fallback imports in #11238,
|
||||
// missed at this call site.
|
||||
export function systrayModuleSpecifier(runtimeDir: string): string {
|
||||
return pathToFileURL(join(runtimeDir, "node_modules", SYSTRAY_PACKAGE)).href;
|
||||
}
|
||||
|
||||
export function resolveSystrayBinName(platform: NodeJS.Platform): string | null {
|
||||
if (platform === "win32") return "tray_windows_release.exe";
|
||||
if (platform === "darwin") return "tray_darwin_release";
|
||||
@@ -71,7 +60,8 @@ export async function loadSystray(): Promise<(new (...args: unknown[]) => unknow
|
||||
// drop the +x bit on extraction (observed on macOS).
|
||||
chmodSystrayBinAt(RUNTIME_DIR, process.platform);
|
||||
try {
|
||||
const mod = await import(systrayModuleSpecifier(RUNTIME_DIR));
|
||||
const modPath = join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE);
|
||||
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}`);
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
* For top-tier languages, a translated `common` + `program` section is included.
|
||||
* All other keys fall back to `en` via i18n.mjs's existing fallback mechanism.
|
||||
*
|
||||
* Run: node bin/cli/scripts/generate-locales.mjs [--force] [--code=<locale>]
|
||||
* --code=<locale> generate only that locale (scripts/i18n/add-locale.mjs uses it
|
||||
* to scaffold the catalog of the locale it is adding)
|
||||
* Run: node bin/cli/scripts/generate-locales.mjs [--force]
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
@@ -19,13 +17,8 @@ const ROOT = join(__dirname, "..", "..", "..");
|
||||
const LOCALES_DIR = join(__dirname, "..", "locales");
|
||||
const I18N_CFG = join(ROOT, "config", "i18n.json");
|
||||
const FORCE = process.argv.includes("--force");
|
||||
const ONLY = process.argv.find((arg) => arg.startsWith("--code="))?.slice("--code=".length) || null;
|
||||
|
||||
const { locales } = JSON.parse(readFileSync(I18N_CFG, "utf8"));
|
||||
if (ONLY && !locales.some((locale) => locale.code === ONLY)) {
|
||||
console.error(`--code=${ONLY} is not listed in config/i18n.json`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// common + program translations for each language code.
|
||||
// Keys that are absent fall back to en automatically.
|
||||
@@ -890,12 +883,12 @@ const TRANSLATIONS = {
|
||||
|
||||
// Languages with no translation in this script — will be created as empty objects
|
||||
// All keys fall back to `en` via i18n.mjs's fallback mechanism.
|
||||
const SCAFFOLD_ONLY = ["bn", "gu", "he", "mr", "ms", "phi", "sw", "ta", "te", "ur"];
|
||||
const SCAFFOLD_ONLY = ["bn", "gu", "he", "in", "mr", "ms", "phi", "sw", "ta", "te", "ur"];
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const locale of locales.filter((candidate) => !ONLY || candidate.code === ONLY)) {
|
||||
for (const locale of locales) {
|
||||
const { code } = locale;
|
||||
if (code === "en" || code === "pt-BR") {
|
||||
skipped++;
|
||||
|
||||
@@ -5,14 +5,10 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./
|
||||
|
||||
async function loadSqlite() {
|
||||
if (process.versions.bun) {
|
||||
try {
|
||||
return { Database: (await import("bun:sqlite")).Database, driver: "bun:sqlite" };
|
||||
} catch (bunError) {
|
||||
// fall through to better-sqlite3 if bun:sqlite fails
|
||||
}
|
||||
return { Database: (await import("bun:sqlite")).Database };
|
||||
}
|
||||
try {
|
||||
return { Database: (await import("better-sqlite3")).default, driver: "better-sqlite3" };
|
||||
return { Database: (await import("better-sqlite3")).default };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
@@ -90,14 +86,12 @@ export function normalizeBunSqliteParams(params) {
|
||||
|
||||
export function createSqliteNativeError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isBun = Boolean(process.versions.bun);
|
||||
const rebuildCmd = isBun ? "bun add better-sqlite3 --trust" : "npm rebuild better-sqlite3";
|
||||
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
|
||||
return new Error(
|
||||
`better-sqlite3 native binding is incompatible with this runtime. ` +
|
||||
`Run \`${rebuildCmd}\` in the OmniRoute project and try again. ` +
|
||||
`Or run: omniroute runtime repair ` +
|
||||
`(rebuilds into a user-writable runtime; works without a C++ toolchain).`
|
||||
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
|
||||
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again. " +
|
||||
"Or run: omniroute runtime repair " +
|
||||
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -106,9 +100,10 @@ export function createSqliteNativeError(error) {
|
||||
message.includes("Cannot find module 'better-sqlite3'")
|
||||
) {
|
||||
return new Error(
|
||||
`better-sqlite3 native binding could not be found (no prebuilt addon for this platform). ` +
|
||||
`Run: omniroute runtime repair ` +
|
||||
`(rebuilds into a user-writable runtime; works without a C++ toolchain).`
|
||||
"better-sqlite3 native binding could not be found (no prebuilt addon for this platform). " +
|
||||
"This is common under `npx`, which runs a fresh, ephemeral install that never built the addon. " +
|
||||
"Run: omniroute runtime repair " +
|
||||
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
|
||||
);
|
||||
}
|
||||
return error;
|
||||
@@ -116,7 +111,7 @@ export function createSqliteNativeError(error) {
|
||||
|
||||
async function openSqliteDatabase(dbPath, options = {}) {
|
||||
const loaded = await loadSqlite();
|
||||
if (loaded.driver === "bun:sqlite" || (process.versions.bun && !loaded.Database)) {
|
||||
if (process.versions.bun) {
|
||||
if (options.fileMustExist && !fs.existsSync(dbPath)) {
|
||||
throw new Error(`SQLite file does not exist: ${dbPath}`);
|
||||
}
|
||||
|
||||
@@ -114,13 +114,10 @@ function writeLinuxSystemdUnit(cliPath) {
|
||||
const unitDir = dirname(linuxSystemdUnitPath());
|
||||
mkdirSync(unitDir, { recursive: true });
|
||||
const envFile = join(userHomeDir(), ".omniroute", ".env");
|
||||
const nodeBinDir = dirname(process.execPath);
|
||||
const userLocalBin = join(userHomeDir(), ".local", "bin");
|
||||
const pathEnv = `${nodeBinDir}:${userLocalBin}:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin`;
|
||||
const lines = [
|
||||
"[Unit]",
|
||||
"Description=OmniRoute AI proxy router",
|
||||
"After=network-online.target graphical-session.target",
|
||||
"After=network-online.target",
|
||||
"Wants=network-online.target",
|
||||
"",
|
||||
"[Service]",
|
||||
@@ -137,7 +134,6 @@ function writeLinuxSystemdUnit(cliPath) {
|
||||
`ExecStart=${buildServeExecLine(cliPath, { tray: false })}`,
|
||||
"Restart=on-failure",
|
||||
"RestartSec=5",
|
||||
`Environment="PATH=${pathEnv}"`,
|
||||
];
|
||||
if (existsSync(envFile)) lines.push(`EnvironmentFile=-${envFile}`);
|
||||
lines.push("", "[Install]", "WantedBy=default.target", "");
|
||||
@@ -280,10 +276,6 @@ function isAgentSelfMac() {
|
||||
}
|
||||
}
|
||||
|
||||
function isDetachedTrayWorker() {
|
||||
return process.argv.includes("--tray-worker");
|
||||
}
|
||||
|
||||
function enableMac() {
|
||||
const plistDir = join(homedir(), "Library", "LaunchAgents");
|
||||
mkdirSync(plistDir, { recursive: true });
|
||||
@@ -308,7 +300,7 @@ function enableMac() {
|
||||
// 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() || isDetachedTrayWorker()) return existsSync(plistPath);
|
||||
if (isAgentSelfMac()) return existsSync(plistPath);
|
||||
try {
|
||||
execSync("launchctl load -w " + JSON.stringify(plistPath), { stdio: "ignore" });
|
||||
} catch {}
|
||||
@@ -321,7 +313,7 @@ function disableMac() {
|
||||
// `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() && !isDetachedTrayWorker()) {
|
||||
if (!isAgentSelfMac()) {
|
||||
try {
|
||||
execSync("launchctl unload -w " + JSON.stringify(plistPath), { stdio: "ignore" });
|
||||
} catch {}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createServer, connect } from "node:net";
|
||||
|
||||
/** Builds arguments for the hidden process that owns the server and tray. */
|
||||
export function buildTrayWorkerArgs({ port, maxRestarts, readyPort, readyToken, tlsCert, tlsKey }) {
|
||||
const args = [
|
||||
"serve",
|
||||
"--tray",
|
||||
"--tray-worker",
|
||||
"--no-open",
|
||||
"--port",
|
||||
String(port),
|
||||
"--max-restarts",
|
||||
String(maxRestarts),
|
||||
"--tray-ready-port",
|
||||
String(readyPort),
|
||||
"--tray-ready-token",
|
||||
readyToken,
|
||||
];
|
||||
if (tlsCert) args.push("--tls-cert", tlsCert);
|
||||
if (tlsKey) args.push("--tls-key", tlsKey);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Builds the platform command that starts the hidden tray worker. */
|
||||
export function buildTrayLaunch({ platform, execPath, cliPath, workerArgs, label }) {
|
||||
if (platform === "darwin") {
|
||||
return {
|
||||
command: "launchctl",
|
||||
args: ["submit", "-l", label, "--", execPath, cliPath, ...workerArgs],
|
||||
options: { stdio: "ignore" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: execPath,
|
||||
args: [cliPath, ...workerArgs],
|
||||
options: { detached: true, stdio: "ignore", windowsHide: true },
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns an error for command modes that conflict with detached tray mode. */
|
||||
export function validateTrayOptions(opts) {
|
||||
if (opts.trayWorker && (!opts.trayReadyPort || !opts.trayReadyToken)) {
|
||||
return "tray worker requires readiness credentials";
|
||||
}
|
||||
if (!opts.tray || opts.trayWorker) return null;
|
||||
if (opts.daemon) return "--tray cannot use --daemon";
|
||||
if (opts.log) return "--tray cannot use --log";
|
||||
if (opts.noRecovery || opts.recovery === false) return "--tray cannot use --no-recovery";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Creates a token-protected loopback server for tray worker readiness. */
|
||||
export async function createTrayReadinessServer(token) {
|
||||
let markReady;
|
||||
const ready = new Promise((resolve) => {
|
||||
markReady = resolve;
|
||||
});
|
||||
const expected = Buffer.from(token);
|
||||
const server = createServer((socket) => {
|
||||
let data = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
if (data.length > 256) socket.destroy();
|
||||
});
|
||||
socket.on("end", () => {
|
||||
const received = Buffer.from(data);
|
||||
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
|
||||
socket.end("ERROR");
|
||||
return;
|
||||
}
|
||||
socket.end("READY");
|
||||
markReady();
|
||||
});
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
return {
|
||||
port: address.port,
|
||||
wait(timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("Tray worker did not become ready")),
|
||||
timeoutMs
|
||||
);
|
||||
ready.then(() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
close() {
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Notifies the parent process that the server and tray are ready. */
|
||||
export async function notifyTrayReady(port, token) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = connect({ host: "127.0.0.1", port }, () => socket.end(token));
|
||||
let reply = "";
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk) => {
|
||||
reply += chunk;
|
||||
});
|
||||
socket.on("end", () => {
|
||||
if (reply === "READY") resolve();
|
||||
else reject(new Error("Tray readiness token was rejected"));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Starts a detached tray worker and waits until its server and tray are ready. */
|
||||
export async function startDetachedTray(
|
||||
{ cliPath, port, maxRestarts, tlsCert, tlsKey, timeoutMs = 60000 },
|
||||
{ platform = process.platform, spawnProcess = spawn } = {}
|
||||
) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const readiness = await createTrayReadinessServer(token);
|
||||
const label = `com.omniroute.tray.${process.pid}.${Date.now()}`;
|
||||
const workerArgs = buildTrayWorkerArgs({
|
||||
port,
|
||||
maxRestarts,
|
||||
readyPort: readiness.port,
|
||||
readyToken: token,
|
||||
tlsCert,
|
||||
tlsKey,
|
||||
});
|
||||
const launch = buildTrayLaunch({
|
||||
platform,
|
||||
execPath: process.execPath,
|
||||
cliPath,
|
||||
workerArgs,
|
||||
label,
|
||||
});
|
||||
const child = spawnProcess(launch.command, launch.args, launch.options);
|
||||
const spawnFailure = new Promise((_, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (platform !== "darwin" || code !== 0) {
|
||||
reject(new Error(`Tray worker exited before readiness with code ${code ?? "unknown"}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
if (platform !== "darwin") child.unref?.();
|
||||
try {
|
||||
await Promise.race([readiness.wait(timeoutMs), spawnFailure]);
|
||||
return { platform, pid: child.pid, label: platform === "darwin" ? label : null };
|
||||
} catch (err) {
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
execFileSync("launchctl", ["bootout", `gui/${process.getuid()}/${label}`], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {}
|
||||
} else if (platform === "win32" && child.pid) {
|
||||
try {
|
||||
execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
||||
} catch {}
|
||||
} else if (child.pid) {
|
||||
try {
|
||||
process.kill(child.pid, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
readiness.close();
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,9 @@ export async function initSystrayUnix(
|
||||
}
|
||||
});
|
||||
|
||||
await tray.ready();
|
||||
tray.ready().catch((err) => {
|
||||
process.stderr.write(`[omniroute][tray] systray2 failed: ${err?.message ?? String(err)}\n`);
|
||||
});
|
||||
|
||||
return tray;
|
||||
}
|
||||
|
||||
@@ -94,15 +94,10 @@ export function ensureAndroidCacheDir(options = {}) {
|
||||
*/
|
||||
export function isFatalInstrumentationHookFailure(text) {
|
||||
if (!text) return false;
|
||||
// Next.js wraps ANY throw inside instrumentation.register() with the generic
|
||||
// "An error occurred while loading instrumentation hook:" prefix, on every
|
||||
// platform (node_modules/next/dist/server/web/globals.js). That prefix alone
|
||||
// therefore cannot identify the Android/Termux cache-probe failure — a bare
|
||||
// generic instrumentation error on win32/desktop would be misreported as the
|
||||
// Android bug and hide the real cause. Only match when the text actually
|
||||
// carries the Android platform marker that Next's getCacheDirectory() emits.
|
||||
// #10028
|
||||
return /Unsupported platform:\s*android/i.test(text);
|
||||
return (
|
||||
/Unsupported platform:\s*android/i.test(text) ||
|
||||
/error occurred while loading instrumentation hook/i.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,66 +100,31 @@ export async function waitForServer(port, timeout = 60000) {
|
||||
// - "hanging": the request timed out waiting for any response — the
|
||||
// process accepted the TCP connection but never answered (#6800).
|
||||
// - "not-listening": nothing is accepting connections on the port at all.
|
||||
// #11766: probe both IPv4 and IPv6 loopback to handle servers listening on
|
||||
// either family (or both).
|
||||
async function pollHealthOnce(port) {
|
||||
const hosts = ["127.0.0.1", "::1"];
|
||||
const outcomes = [];
|
||||
|
||||
// Probe both loopback families concurrently
|
||||
const results = await Promise.all(
|
||||
hosts.map(async (host) => {
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
return { host, outcome: res.ok ? "ready" : "fast-reject" };
|
||||
} catch (err) {
|
||||
const outcome = err?.name === "TimeoutError" ? "hanging" : "error";
|
||||
return { host, outcome };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
outcomes.push(...results.map((r) => r.outcome));
|
||||
|
||||
// If either family is ready, the server is ready
|
||||
if (outcomes.includes("ready")) return "ready";
|
||||
|
||||
// If either family is fast-reject, treat as fast-reject
|
||||
// (TCP is listening and rejecting, just route not ready yet)
|
||||
if (outcomes.includes("fast-reject")) return "fast-reject";
|
||||
|
||||
// If either family is hanging, server accepted TCP but not answering
|
||||
// (still booting, must not report as ready per #6800)
|
||||
if (outcomes.includes("hanging")) return "hanging";
|
||||
|
||||
// Both families failed — check if either port is actually listening
|
||||
// If listening, then errors above are route-level (fast-reject case)
|
||||
const listening = await isPortListening(port).catch(() => false);
|
||||
return listening ? "fast-reject" : "not-listening";
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
return res.ok ? "ready" : "fast-reject";
|
||||
} catch (err) {
|
||||
if (err?.name === "TimeoutError") return "hanging";
|
||||
const listening = await isPortListening(port).catch(() => false);
|
||||
return listening ? "fast-reject" : "not-listening";
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortListening(port) {
|
||||
const net = await import("node:net");
|
||||
// #11766: check both IPv4 and IPv6 loopback. Return true if either is listening.
|
||||
const hosts = ["127.0.0.1", "::1"];
|
||||
const results = await Promise.all(
|
||||
hosts.map(
|
||||
(host) =>
|
||||
new Promise((resolve) => {
|
||||
const socket = net.connect({ host, port, timeout: 1000 });
|
||||
const finish = (ok) => {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {}
|
||||
resolve(ok);
|
||||
};
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("error", () => finish(false));
|
||||
socket.once("timeout", () => finish(false));
|
||||
})
|
||||
)
|
||||
);
|
||||
return results.some((ok) => ok);
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect({ host: "127.0.0.1", port, timeout: 1000 });
|
||||
const finish = (ok) => {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {}
|
||||
resolve(ok);
|
||||
};
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("error", () => finish(false));
|
||||
socket.once("timeout", () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,34 +24,3 @@ export function resolveServerHost(
|
||||
}
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
||||
|
||||
/**
|
||||
* Boot-time exposure warning (GHSA-wmgv-ph3p-rv57): the shipped default binds
|
||||
* all interfaces while the inference plane requires no credentials, so any
|
||||
* LAN peer can spend the operator's quota. That local-first posture is a
|
||||
* deliberate, documented default — but it must be LOUD at startup so an
|
||||
* operator who never read the docs still learns the two escape hatches.
|
||||
*
|
||||
* Returns the warning text when the server will listen on a non-loopback
|
||||
* interface with no API-key requirement, or null when the exposure is closed.
|
||||
*
|
||||
* @param {NodeJS.ProcessEnv} [env]
|
||||
* @param {string} [host]
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function resolveExposureWarning(env = process.env, host = resolveServerHost(env)) {
|
||||
if (LOOPBACK_HOSTS.has(host)) return null;
|
||||
const requireKey = String(env.REQUIRE_API_KEY || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (requireKey === "true" || requireKey === "1" || requireKey === "yes") return null;
|
||||
return (
|
||||
`SECURITY: listening on ${host} with NO API-key requirement — the inference ` +
|
||||
`plane (/v1/*) is reachable by ANY device that can route to this host, and ` +
|
||||
`requests are billed to your configured providers. This local-first default ` +
|
||||
`is intentional, but on an untrusted network either set REQUIRE_API_KEY=true ` +
|
||||
`or bind loopback with OMNIROUTE_SERVER_HOST=127.0.0.1.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { sep } from "node:path";
|
||||
|
||||
/**
|
||||
* A `.env` inside the installed package directory does not survive an update:
|
||||
* `npm i -g` replaces that directory wholesale, and postinstall recreates the
|
||||
* file from `.env.example`. The CLI announces every env file it loads without
|
||||
* distinguishing the ones that last from the one that doesn't.
|
||||
*
|
||||
* Returns the warning to print, or null when there is nothing worth saying.
|
||||
*
|
||||
* Two conditions, both required, so a development checkout never sees this:
|
||||
* - the file sits inside the package root, and that root is inside a
|
||||
* `node_modules` directory — i.e. an installed package, not a checkout,
|
||||
* where the same path is stable and documented in SETUP_GUIDE.md;
|
||||
* - the file actually supplied at least one value. First writer wins, so a
|
||||
* file entirely shadowed by a durable one supplied nothing, and losing it
|
||||
* costs nothing.
|
||||
*
|
||||
* @param {{ envPath: string, packageRoot: string, durableEnvPath: string, suppliedKeys: boolean }} args
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function describeVolatileEnvWarning({ envPath, packageRoot, durableEnvPath, suppliedKeys }) {
|
||||
if (!suppliedKeys) return null;
|
||||
if (envPath === durableEnvPath) return null;
|
||||
if (!isInsideInstalledPackage(packageRoot)) return null;
|
||||
if (!envPath.startsWith(packageRoot + sep)) return null;
|
||||
|
||||
return (
|
||||
`${envPath} lives inside the installed package: updating OmniRoute replaces it. ` +
|
||||
`Move the values you set to ${durableEnvPath}, which updates leave alone.`
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the path sits under a `node_modules` directory. */
|
||||
function isInsideInstalledPackage(dir) {
|
||||
return typeof dir === "string" && dir.split(sep).includes("node_modules");
|
||||
}
|
||||
@@ -44,18 +44,6 @@ export function getSecureFloorForMajor(major) {
|
||||
}
|
||||
|
||||
export function getNodeRuntimeSupport(version = process.versions.node) {
|
||||
if (process.versions.bun) {
|
||||
return {
|
||||
nodeVersion: `bun-${process.versions.bun} (Node.js API ${version})`,
|
||||
nodeCompatible: true,
|
||||
reason: "supported-bun",
|
||||
supportedRange: SUPPORTED_NODE_RANGE + " || Bun >=1.1.0",
|
||||
supportedDisplay: SUPPORTED_NODE_DISPLAY + ", or Bun 1.1+",
|
||||
recommendedVersion: `v${RECOMMENDED_NODE_VERSION}`,
|
||||
minimumSecureVersion: null,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseNodeVersion(version);
|
||||
const secureFloor = getSecureFloorForMajor(parsed.major);
|
||||
const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false;
|
||||
|
||||
@@ -17,19 +17,13 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
let updateNotifier = null;
|
||||
try {
|
||||
updateNotifier = (await import("update-notifier")).default;
|
||||
} catch {
|
||||
// update-notifier is optional in pruned standalone environments
|
||||
}
|
||||
import updateNotifier from "update-notifier";
|
||||
import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs";
|
||||
import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs";
|
||||
import { getDefaultDataDir } from "./cli/data-dir.mjs";
|
||||
import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs";
|
||||
import { isVersionFastPath } from "./cli/utils/versionFastPath.mjs";
|
||||
import { parseEnvValue } from "./cli/utils/parseEnvValue.mjs";
|
||||
import { describeVolatileEnvWarning } from "./cli/utils/volatileEnvPath.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -92,7 +86,9 @@ function migrateElectronServerEnv(dataDir) {
|
||||
const serverEnvPath = join(dataDir, "server.env");
|
||||
if (existsSync(envPath) || !existsSync(serverEnvPath)) return;
|
||||
writeFileSync(envPath, readFileSync(serverEnvPath, "utf-8"), "utf-8");
|
||||
console.log(` \x1b[2m♻ Migrated Electron secrets from ${serverEnvPath} to ${envPath}\x1b[0m`);
|
||||
console.log(
|
||||
` \x1b[2m♻ Migrated Electron secrets from ${serverEnvPath} to ${envPath}\x1b[0m`
|
||||
);
|
||||
} catch {
|
||||
// Ignore errors migrating server.env — fall back to normal env loading below.
|
||||
}
|
||||
@@ -163,21 +159,6 @@ function loadEnvFile() {
|
||||
const setter = winner ? winner : "the environment";
|
||||
console.warn(` \x1b[33m⚠ ${key} in ${loser} is ignored, ${setter} set it first\x1b[0m`);
|
||||
}
|
||||
|
||||
// The package directory is replaced by the next `npm i -g`, so a .env kept
|
||||
// there is silently lost. Say so once, and only when that file actually
|
||||
// supplied something.
|
||||
const durableEnvPath = join(process.env.DATA_DIR || getDefaultDataDir(), ".env");
|
||||
const suppliedKeys = [...keyOrigin.values()].some((origin) => origin === join(ROOT, ".env"));
|
||||
const volatileWarning = describeVolatileEnvWarning({
|
||||
envPath: join(ROOT, ".env"),
|
||||
packageRoot: ROOT,
|
||||
durableEnvPath,
|
||||
suppliedKeys,
|
||||
});
|
||||
if (volatileWarning && loadedEnvPaths.includes(join(ROOT, ".env"))) {
|
||||
console.warn(` \x1b[33m⚠ ${volatileWarning}\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile();
|
||||
@@ -261,33 +242,24 @@ if (shouldProvisionStorageKey(process.argv)) {
|
||||
const langEnv = process.env.OMNIROUTE_LANG;
|
||||
const chosen = langArg || langEnv;
|
||||
if (chosen) {
|
||||
const { setLocale } = await import(pathToFileURL(join(ROOT, "bin", "cli", "i18n.mjs")).href);
|
||||
const { setLocale } = await import(
|
||||
pathToFileURL(join(ROOT, "bin", "cli", "i18n.mjs")).href
|
||||
);
|
||||
setLocale(chosen);
|
||||
}
|
||||
}
|
||||
|
||||
// Register update notifier — checks npm once per 24h, notifies on exit via stderr.
|
||||
const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
||||
const _notifier = updateNotifier
|
||||
? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 })
|
||||
: null;
|
||||
const _notifier = updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 });
|
||||
process.on("exit", () => {
|
||||
if (!_notifier || !_notifier.update) return;
|
||||
if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return;
|
||||
if (process.env.CI) return;
|
||||
if (process.argv.includes("--quiet") || process.argv.includes("-q")) return;
|
||||
const outputIdx = process.argv.indexOf("--output");
|
||||
const outputVal = outputIdx >= 0 ? process.argv[outputIdx + 1] : null;
|
||||
if (outputVal === "json" || outputVal === "jsonl" || outputVal === "csv") return;
|
||||
if (
|
||||
process.argv.some(
|
||||
(a) =>
|
||||
a.startsWith("--output=json") ||
|
||||
a.startsWith("--output=jsonl") ||
|
||||
a.startsWith("--output=csv")
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (process.argv.some((a) => a.startsWith("--output=json") || a.startsWith("--output=jsonl") || a.startsWith("--output=csv"))) return;
|
||||
if (_notifier.update) {
|
||||
_notifier.notify({
|
||||
defer: false,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- **feat(admission):** add lane-aware admission probes for combo/fusion/chaos fan-out (fail-open, queueing disabled), an env-wins `OMNIROUTE_CHAT_VIRTUAL_LANES` activation flag applied at boot, and adaptive-lane visibility in the `omniroute_get_health` MCP tool (related to #9654)
|
||||
- **docs(mcp):** complete the MCP server README tool reference so the `schemas/` catalog is fully covered (agent-skills, oneproxy, web, tool-search, combo/routing, pricing and DB-health tools were previously only discoverable via `omniroute_tool_search`)
|
||||
1
changelog.d/features/10057-docker-aware-auto-config.md
Normal file
1
changelog.d/features/10057-docker-aware-auto-config.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(cli):** container-aware auto-config — `setup-*`, `omniroute configure`, `omniroute config set` and the CLI-tool config APIs now refuse to write into a containerised OmniRoute's ephemeral home (CLI exits `2`, API returns `422` with `containerEphemeralTarget`) and point at the host-CLI or bind-mount setup instead; `--allow-container-write` / `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` opt back in. Also fixes `CLI_CONFIG_HOME` so the Compose `host` profile's `/host-home` bind mounts are honoured instead of silently falling back to the container home. (#10057)
|
||||
1
changelog.d/features/10273-dashboard-embed-csp.md
Normal file
1
changelog.d/features/10273-dashboard-embed-csp.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(dashboard): opt-in `DASHBOARD_ALLOW_EMBED=vscode` relaxes CSP `frame-ancestors` to `'self' vscode-webview:` and drops `X-Frame-Options` for HTML pages only, so the dashboard renders inside the VS Code Simple Browser (OmniCopilot). Default posture unchanged — API routes stay unframable (#10273)
|
||||
1
changelog.d/features/10303-healthz-event-loop-lag.md
Normal file
1
changelog.d/features/10303-healthz-event-loop-lag.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(resilience):** warn when `/healthz` is served under event-loop lag ≥200ms so a slow 200 is visible as sick, not healthy ([#10303](https://github.com/diegosouzapw/OmniRoute/issues/10303))
|
||||
1
changelog.d/features/10316-livez-endpoint.md
Normal file
1
changelog.d/features/10316-livez-endpoint.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(docker):** add `GET`/`HEAD` `/livez` as a process-alive probe, distinct from `/healthz` readiness ([#10316](https://github.com/diegosouzapw/OmniRoute/issues/10316))
|
||||
1
changelog.d/features/10389-cloudflare-playground.md
Normal file
1
changelog.d/features/10389-cloudflare-playground.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): add **Cloudflare AI Playground** as a No Auth provider (`cloudflare-playground`, alias `cfp`) — free anonymous chat over the reverse-engineered `cf_agent` WebSocket protocol (PartySocket transport, no account/API key/cookies) with GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro, gpt-oss-120B, Llama 3.3 70B, Qwen2.5 Coder 32B and 14 more curated models. The executor drives a headless Chromium via Playwright (the WS upgrade is TLS-fingerprint-gated), translates the `cf_agent` frame stream into OpenAI SSE, and surfaces upstream rate limits (3021) as HTTP 429. Fixes #10389
|
||||
@@ -0,0 +1,2 @@
|
||||
- **feat(providers):** AI Horde accepts an optional registered API key and advertises only live image models that currently have workers ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542))
|
||||
- **fix(providers):** AI Horde Check validates keys via `/v2/find_user` instead of the unauthenticated OpenAI models list ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user