Compare commits

..

11 Commits

Author SHA1 Message Date
Xiangzhe
d29829b8c4 fix(security): reduce the health payload for anonymous callers
GET /api/monitoring/health returned host-fingerprinting detail (app/node
version, pid, memory, provider breaker config, MCP paths) to any anonymous
caller — the common case on a keyless install. A non-management caller now
receives only the liveness verdict (status + setupComplete), which is all a
health / load-balancer probe needs; a management principal still gets the full
payload. REQUIRE_API_KEY stays false by default (local-first) per operator choice.

Reported by @kaimandalic via GHSA-mvf8-qc78-5mxm (information-disclosure portion).
2026-08-21 14:49:32 -03:00
Xiangzhe
f1b4a3d93e fix(security): require a credential before echoing the CORS Origin on /v1
The /v1* surface echoes an arbitrary Origin so token-authenticated browser /
Electron clients can read the response (#5242) — safe only because
Authorization / x-api-key are never auto-attached. On a keyless install /v1 is
served anonymously, so a credential-less cross-origin page was echoed its own
Origin and could drive the gateway (GHSA-7px7). The echo now requires the request
to actually carry a credential (Authorization / x-api-key / x-goog-api-key or the
auth_token cookie), or be a CORS preflight; truly anonymous cross-origin requests
no longer get it. #5242 token and dashboard-session clients are unaffected.

Reported by @Upshivam786 via GHSA-7px7-29v2-m97p.
2026-08-21 14:49:32 -03:00
Xiangzhe
ad15811e1a fix(security): honor REQUIRE_API_KEY on the /a2a route
/a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the
pipeline enforces for /v1 never ran there — the route accepted every caller
whenever OMNIROUTE_API_KEY was unset (the shipped default). authenticate() now
applies the same posture directly: a valid OmniRoute key when REQUIRE_API_KEY is
on, the legacy explicit A2A key otherwise, and keyless local-first only when
nothing is configured (matching /v1). A2A stays off by default.

Reported by @rafaelfiguereod-stack via GHSA-v54m-6rm3-p565.
2026-08-21 14:49:32 -03:00
Xiangzhe
a03cb03110 fix(security): route OAuth auto-import through the LOCAL_ONLY tier
The cursor / kiro / raycast auto-import routes read host-local credential files,
but the broad /api/oauth/ PUBLIC prefix classified them PUBLIC — which skips the
LOCAL_ONLY tier entirely, so the loopback-only guard never ran. They are now
excluded from PUBLIC (classify MANAGEMENT) and added to LOCAL_ONLY_API_PREFIXES,
so a non-loopback caller is rejected before the handler runs. OAuth callbacks and
browser flows under /api/oauth/ stay PUBLIC.

Reported by @ntdat812 via GHSA-wgwc-crjm-pmwv and @koyokr via GHSA-gxv4-955v-v6cm.
2026-08-21 14:49:31 -03:00
Xiangzhe
81550a8629 fix(security): require management scope for OAuth import/auto-import routes
The OAuth import and auto-import routes create or read provider credentials
(connection injection, Cursor token disclosure), but guarded only with
isAuthenticated() — which, because /api/oauth/ is PUBLIC-classified, accepts any
valid client API key. All ten routes now go through requireManagementAuth, so a
non-manage key gets 403 (401 with no credential) while a dashboard session or
manage-scope key still works. Default requireLogin=true is unaffected for
legitimate operators; keyless requireLogin=false stays open by design.

Reported by @EQSTLab via GHSA-mg76-rhpx-gvw3 and @koyokr via GHSA-gxv4-955v-v6cm.
2026-08-21 14:49:31 -03:00
Xiangzhe
4f5b647011 fix(security): validate the AWS region on the device-code route (SSRF)
The kiro / amazon-q device-code action interpolated a caller-supplied `region`
into the AWS OIDC endpoint URLs that requestDeviceCode() fetches, with no
validation — an attacker-shaped region (userinfo/fragment) could re-point the
outbound host to an internal target or the cloud-metadata service. `region` is
now checked against the canonical AWS region shape (AWS_REGION_PATTERN, already
used by pollToken) and rejected with a 400 before any outbound fetch.

Reported by @daniel-mertz via GHSA-7x63-xvp5-w2jc.
2026-08-21 14:49:31 -03:00
Xiangzhe
50f5cecb80 fix(security): don't auto-adopt an unverified listener on a service port
decidePreSpawn adopted any listener that returned a 2xx on the health path, so a
local process that squats an embedded-service port before the supervisor starts
it would be adopted — receiving the injected service API key and script execution
inside the dashboard origin. Adoption is now opt-in
(OMNIROUTE_ADOPT_EXISTING_SERVICE=1); by default a healthy-but-unverified listener
yields the same actionable error as a held-but-unhealthy port. The embedded-UI CSP
hardening (strict embed CSP / the dead scriptSrc ternary) is a separate follow-up.

Reported by @rafaelfiguereod-stack via GHSA-wg9p-6m2g-4v27.
2026-08-21 14:49:31 -03:00
Xiangzhe
60060a6dca fix(security): mask the Obsidian WebDAV password from non-management callers
GET /api/settings/obsidian/webdav returned the plaintext webdavPassword to any
caller the handler admitted — including an anonymous caller reaching it through
the requireLogin=false open mode (the default management pipeline already blocks
non-manage keys). The plaintext is now returned only to a genuine management
principal (dashboard session or manage-scope key); everyone else gets a
`webdavPasswordSet` flag instead. The dashboard's authenticated reveal view is
unchanged.

Reported by @0raN9ewww via GHSA-62vw-4m6w-cqqq (and the credential-exposure
portion of GHSA-p855-p6fm-76r3).
2026-08-21 14:49:30 -03:00
Xiangzhe
14078b2f2b fix(security): SSRF guard on the executor dispatch path (provider baseUrl)
A persisted, caller-supplied providerSpecificData.baseUrl reached fetch() on the
runtime dispatch path with no SSRF guard, so a manage-scope actor (or an
anonymous one on a keyless install) could point a provider at loopback /
internal / cloud-metadata hosts and reach the instance metadata service.
BaseExecutor now mirrors the provider validation guard before every upstream
fetch (fetchWithStartTimeout covers retries/fallback URLs; countTokens too), with
the same call added to the glm and nlpcloud executors' own fetch paths. Local /
self-hosted providers stay exempt; default block-metadata mode stops the
cloud-metadata IMDS pivot, public-only mode also blocks private targets.

Reported by @rafaelfiguereod-stack via GHSA-4f49-hj64-448x.
2026-08-21 14:49:30 -03:00
Xiangzhe
38a2f8ba4d fix(security): enforce per-key policy for a bare x-api-key (allowedModels bypass)
The CLIENT_API auth layer accepts a plain `x-api-key` (no anthropic-version), but
enforceApiKeyPolicy resolved the key via the Issue-#2225-gated extractApiKey(),
which ignores that header — so a valid restricted key sent as a bare x-api-key
passed auth while skipping its allowedModels / budget / rate-limit policy entirely.
Resolve the ungated x-api-key / x-goog-api-key in the policy layer too; unknown
keys still fail open, so only real keys are affected. extractApiKey() (used by
MANAGEMENT routes) keeps its local-mode gating.

Reported by @Benson-mk via GHSA-2phc-xp22-9f56 and GHSA-m3cj-q455-6wfr.
2026-08-21 14:49:30 -03:00
Xiangzhe
c46f048ca2 fix(security): bind MCP memory tools to the authenticated caller (IDOR)
resolveMemoryOwnerId() let a caller-supplied `apiKeyId` win over the resolved
caller principal, so any MCP caller could read/write/delete another principal's
memories by putting a different id in the tool arguments. The resolved caller
(HTTP auth headers on SSE/Streamable HTTP, OMNIROUTE_API_KEY on stdio) now wins;
the explicit argument is only honored as a fallback when no caller can be
resolved (a bare local stdio process, already trusted).

Reported by @rafaelfiguereod-stack via GHSA-cpv3-xr7r-xf8q.
2026-08-21 14:49:30 -03:00
6274 changed files with 128916 additions and 351012 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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"

View File

@@ -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: "/"

View File

@@ -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

View File

@@ -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

View File

@@ -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 1416 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

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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 2732 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:

View File

@@ -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]

View File

@@ -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

View File

@@ -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
View File

@@ -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/

View File

@@ -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''',
]

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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.)

View File

@@ -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": [

View File

@@ -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",

View File

@@ -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}`;
}

View File

@@ -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)));
}
},
}),
);
}

View File

@@ -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}`
);
});

View File

@@ -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"]);
});

View File

@@ -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)"
);
});

View File

@@ -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;
}
});

View File

@@ -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"
);
});

View File

@@ -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");

View File

@@ -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
View File

@@ -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 -->

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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 1595% 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 1595% 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 @@
[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/U47eFqAXCn)
[![Telegram](https://img.shields.io/badge/Telegram-26A5E4?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/omnirouteOficial)
[![WhatsApp Global](https://img.shields.io/badge/WhatsApp_Global-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/FvuCbrpZmQ6I85n2vW5QIC?s=cl&p=a&mlu=4)
[![WhatsApp Brasil](https://img.shields.io/badge/WhatsApp_Brasil-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/KWgatljAjmbELQory59Oti?s=cl&p=a&mlu=4)
[![WhatsApp Global](https://img.shields.io/badge/WhatsApp_Global-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
[![WhatsApp Brasil](https://img.shields.io/badge/WhatsApp_Brasil-25D366?style=for-the-badge&logo=whatsapp&logoColor=white)](https://chat.whatsapp.com/LTSpdFhXTxjH4R6CCNiKWz)
[![Website](https://img.shields.io/badge/Website-omniroute.online-blue?logo=google-chrome&logoColor=white)](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 &amp; 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 1595%, ~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&apos;s docs."/>
<sub>📊 Full methodology &amp; per-feature detail vs 9router, OpenRouter, CLIProxyAPI &amp; LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
@@ -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 &amp; 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 &amp; 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 ~12GiB under two overlapping long contexts. Size the container above the heap (native buffers sit outside V8):
| Workload | Heap (`-e OMNIROUTE_MEMORY_MB`) | Container (`--memory`) |
| ----------------------------------- | ------------------------------- | ---------------------- |
| Dashboard / light chat | `1024` (image default) | ≥2g |
| One coding agent | `8192` | ≥10g |
| Two concurrent long `/v1/responses` | `10240``12288` | ≥1216g |
| Workload | Heap (`-e OMNIROUTE_MEMORY_MB`) | Container (`--memory`) |
| --- | --- | --- |
| Dashboard / light chat | `1024` (image default) | ≥2g |
| One coding agent | `8192` | ≥10g |
| Two concurrent long `/v1/responses` | `10240``12288` | ≥1216g |
```bash
docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
@@ -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>🎬 #5Instagram</b><br/>
<sub>buildwithai.club — 347,652 views</sub>
<b>🎬 #3YouTube 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>&gt;=22.22.2 &lt;23 || &gt;=24.0.0 &lt;27</code></td></tr>
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 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. &amp; Randi"/><br/>
<b>R.D. &amp; 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>
[![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=639&columns=20&anon=1)](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>
[![Contributors](https://contrib.rocks/image?repo=diegosouzapw/OmniRoute&max=400&columns=20&anon=1)](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 -->

View File

@@ -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)** —

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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 });
}

View File

@@ -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/

View File

@@ -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);
});

View File

@@ -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
}),
});
/**

View File

@@ -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")

View File

@@ -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 }) => {

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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" },
};
/**

View File

@@ -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:");

View File

@@ -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

View File

@@ -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);

View File

@@ -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 = [

View File

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

View File

@@ -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);

View File

@@ -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`);
});

View File

@@ -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;
}
}

View File

@@ -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);
});
}

View File

@@ -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);

View File

@@ -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 });
}

View File

@@ -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);
}

View File

@@ -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")

View File

@@ -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}`);

View File

@@ -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;
}

View File

@@ -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`);
}

View File

@@ -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
View File

@@ -0,0 +1,5 @@
{
"redis": {
"description": "Luncurkan kontainer Redis lokal dengan satu klik (Podman atau Docker) untuk caching dan pelacakan kuota OmniRoute"
}
}

View File

@@ -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",

View File

@@ -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": "未安装可选包"
}
}

View File

@@ -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": "未安裝可選套件"
}
}

View File

@@ -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");
}

View File

@@ -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 };
}

View File

@@ -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"), {

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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" };

View File

@@ -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}`);

View File

@@ -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++;

View File

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

View File

@@ -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 {}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

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

View File

@@ -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));
});
}

View File

@@ -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.`
);
}

View File

@@ -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");
}

View File

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

View File

@@ -17,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,

View File

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

View File

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

View File

@@ -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)

View 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))

View 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))

View File

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

View File

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

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