Compare commits

..

9 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
2e7705debb fix(dashboard): restore the 8 bulk-import state declarations deleted by #6625 hook extraction (ReferenceError: bulkImportOpen — release E2E) 2026-07-13 06:51:20 -03:00
Diego Rodrigues de Sa e Souza
ffdc1282de fix(dashboard): restore poolLoaded/poolSaving state deleted by #6909 refactor (settings page runtime crash, caught by release E2E) 2026-07-13 06:07:31 -03:00
Diego Rodrigues de Sa e Souza
3cf0b1cd58 fix(security): linear-time Basic-auth regex in the dev webdav handler (CodeQL js/polynomial-redos #708) 2026-07-13 05:00:49 -03:00
Diego Rodrigues de Sa e Souza
1c3cca399b fix(combo,ws): comboStickyRoundRobinLimit inherits instead of shadowing batched default; LiveWS standalone script boots again (#6678/#6072 follow-ups caught by release CI) 2026-07-13 03:35:35 -03:00
Diego Rodrigues de Sa e Souza
7e9962eac0 fix(skills): remove uncataloged skills/cli-skill-collector orphan (added by #6294 without a catalog entry — skills/ is generated from the catalog) 2026-07-13 03:19:48 -03:00
Diego Rodrigues de Sa e Souza
7eaff019b2 chore(quality): allowlist remaining verified assert migrations (#6862 GPT-5.6 matrix, #6675 provider removals) 2026-07-13 03:18:45 -03:00
Diego Rodrigues de Sa e Souza
213b4175f1 chore(quality): allowlist verified assert reductions from #6897 de-flake and #6862 stronger deepEqual (test-masking) 2026-07-13 03:02:49 -03:00
Diego Rodrigues de Sa e Souza
bf4032abaf docs(release): v3.8.47 feature documentation sync (What's New + doc updates + provider reference regen) 2026-07-13 02:19:05 -03:00
Diego Rodrigues de Sa e Souza
67601cc5e6 docs(changelog): v3.8.47 reconciliation — aggregate 125 fragments, backfill 41 missing bullets, contributors hall (55), date header 2026-07-13 02:12:23 -03:00
3641 changed files with 112318 additions and 478193 deletions

View File

@@ -40,11 +40,6 @@ INITIAL_PASSWORD=CHANGEME
# also if you want to share the same database as "npm run dev" use "./data"
# DATA_DIR=/var/lib/omniroute
# Fallback alias for DATA_DIR, checked only when DATA_DIR is unset.
# Used by: open-sse/executors/promptql/threadSticky.ts — locates the PromptQL
# executor's on-disk thread-sticky session cache. Leave unset to rely on DATA_DIR.
# OMNIROUTE_DATA_DIR=/var/lib/omniroute
# 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.
@@ -80,21 +75,8 @@ PORT=20128
# Base path (URL subpath) when serving OmniRoute behind a reverse proxy under a subpath.
# Used by: next.config.mjs — sets Next.js `basePath`; auth redirects are basePath-aware.
# Also mirrored to NEXT_PUBLIC_OMNIROUTE_BASE_PATH at build time so the dashboard
# endpoint display (useDisplayBaseUrl) shows https://host/omniroute/v1 instead of
# https://host/v1. Rebuild after changing this value (Next basePath is build-time).
# Default: "" (served at the domain root). Example: /omniroute to serve under https://host/omniroute
# OMNIROUTE_BASE_PATH=
#
# Browser-visible mirror of OMNIROUTE_BASE_PATH, inlined at build time so the
# dashboard endpoint display can read it client-side. Set it to the same value
# as OMNIROUTE_BASE_PATH; when unset the hook falls back to OMNIROUTE_BASE_PATH.
# Used by: src/shared/hooks/useDisplayBaseUrl.ts
# NEXT_PUBLIC_OMNIROUTE_BASE_PATH=
#
# Optional: set the public origin *with* the same path so OAuth and display URLs
# stay consistent without relying on window.location.origin alone:
# NEXT_PUBLIC_BASE_URL=https://host/omniroute
# Split-port mode: serve Dashboard and API on separate ports for network isolation.
# Used by: src/lib/runtime/ports.ts — overrides PORT for each service.
@@ -106,14 +88,6 @@ PORT=20128
# Used by: src/sse/utils/backpressure.ts — disabled when unset/0.
# OMNI_MAX_CONCURRENT_CONNECTIONS=0
# Optional OmniRoute-to-OmniRoute peer chaining guard. Give every instance a
# unique ID and allowlist only the other OmniRoute base URLs it may call.
# Requests to allowlisted peers carry X-OmniRoute-Peer-Trace; repeated instances
# and exhausted hop budgets are rejected with HTTP 508 before provider routing.
# OMNIROUTE_INSTANCE_ID=gateway-a
# OMNIROUTE_PEER_URLS=http://gateway-b:20128/v1
# OMNIROUTE_PEER_MAX_HOPS=4
# Port for the real-time WebSocket live monitoring server.
# Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts
# Default: 20132
@@ -248,7 +222,7 @@ CONTAINER_HOST=docker
# - orbstack: OrbStack (high-perf Linux VM + docker shim on macOS)
# - podman: Podman (rootless, daemonless)
# - docker: Docker (default fallback)
# (defined under SKILLS & SANDBOXING section below)
SKILLS_SANDBOX_RUNTIME=auto
# ═══════════════════════════════════════════════════════════════════════════════
# 4. SECURITY & AUTHENTICATION
@@ -314,25 +288,18 @@ ALLOW_API_KEY_REVEAL=false
# Default: 10485760 (10 MB)
# MAX_BODY_SIZE_BYTES=10485760
# Atomic admission for POST /v1/chat/completions (#7846). Large request bodies
# amplify into multiple transient representations during parsing, compression, and
# provider dispatch. Heavyweight capacity is reserved before parsing; excess work
# receives 503 + Retry-After instead of overlapping until the process OOMs.
# Heap-pressure-aware admission for POST /v1/chat/completions (#5152). A large
# coding-agent "compact" body amplifies into hundreds of MB of transient JS objects
# on the combo path; concurrent compacts can stack past the V8 heap ceiling and OOM
# the process. These shed a LARGE body with 503 (Retry-After) only while the heap is
# already under pressure — healthy heap admits every body untouched.
# Used by: src/shared/middleware/chatBodyAdmission.ts
# Actual bodies at or above this size require a heavyweight lease. Default 262144 (256 KB).
# Bodies below this size skip the guard entirely (heap not even sampled). Default 262144 (256 KB).
# OMNIROUTE_CHAT_LARGE_BODY_BYTES=262144
# Actual-byte hard cap enforced during bounded ingestion. Default 52428800 (50 MB).
# Hard cap — bodies above this are rejected with 413 before any clone/parse. Default 52428800 (50 MB).
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
# Maximum heavyweight requests simultaneously admitted in one process. Default 1.
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
# Message count that classifies an otherwise small body as heavyweight. Default 200.
# OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT=200
# Tool count that classifies an otherwise small body as heavyweight. Default 64.
# OMNIROUTE_CHAT_HEAVY_TOOL_COUNT=64
# Conservative string-size token estimate that classifies a request as heavyweight. Default 32000.
# OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS=32000
# Hard message-count cap; excess receives compact-required 413. Default 800.
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=800
# Shed large bodies once heapUsed/heap_size_limit reaches this ratio (0<r<1). Default 0.75.
# OMNIROUTE_CHAT_HEAP_SHED_RATIO=0.75
# Hard cap (bytes) for a non-streaming upstream response buffered fully into memory
# (#5152). Past this the upstream reader is cancelled and the request fails fast
@@ -373,24 +340,16 @@ ALLOW_API_KEY_REVEAL=false
# ── Request-Side: Prompt Injection Guard ──
# Scans incoming messages for prompt injection patterns before routing.
# Used by: src/middleware/promptInjectionGuard.ts
# Default ON when unset. Set to false/0/no/off to disable. Truthy: true/1/yes/on.
# INPUT_SANITIZER_ENABLED=false
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = legacy (does NOT strip injection; use PII_REDACTION_ENABLED for request PII)
# INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
# INPUT_SANITIZER_ENABLED=true
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = strip patterns
# Legacy aliases for INPUT_SANITIZER_MODE / INPUT_SANITIZER_BLOCK_THRESHOLD (same effect).
# Legacy alias for INPUT_SANITIZER_MODE (same effect).
# INJECTION_GUARD_MODE=warn
# INJECTION_GUARD_BLOCK_THRESHOLD=high
# PII detection in incoming requests (emails, phone numbers, SSNs, etc.).
# Used by: src/middleware/promptInjectionGuard.ts — extends injection guard.
# PII_REDACTION_ENABLED=false
# Redacts well-known API-key / secret-token patterns (OpenAI, Anthropic, GitHub,
# Slack, etc.) from request/response payloads before they reach providers/clients.
# Opt-in; mirrors PII_REDACTION_ENABLED. Used by: src/lib/guardrails/credentialMasker.ts.
# CREDENTIAL_REDACTION_ENABLED=false
# Minimum streaming window size for PII detection (bytes). Default: 200.
# Used by: src/lib/streamingPiiTransform.ts.
# PII_WINDOW_SIZE=200
@@ -546,9 +505,7 @@ NEXT_PUBLIC_CLOUD_URL=
#OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
# OpenCode Go has no public quota API — this has no default and stays
# unset unless you explicitly opt in to a self-hosted/mirrored endpoint:
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings
@@ -690,15 +647,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Legacy alias for OMNIROUTE_API_KEY.
# ROUTER_API_KEY=
# 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
# Timeout (ms) for a single Issue Agent recorded-triage run. Clamped to an internal
# maximum; falls back to the built-in default when unset or invalid.
# Used by: src/lib/issueAgent/execution.ts.
# OMNIROUTE_ISSUE_AGENT_TIMEOUT_MS=
# CLI remote-mode context/profile for `omniroute` commands (overrides the active
# context in the local contexts store). Equivalent to the `--context <name>` flag.
# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode).
@@ -867,11 +815,10 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: open-sse/executors/antigravity.ts — escape hatch for multi-project setups.
# OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=0
# Control Antigravity Google One AI credit usage. Used by:
# open-sse/services/antigravityCredits.ts — accepts off, retry, or always.
# off (default): never use credits; retry: use credits once after eligible quota 429;
# always: use credits on the first request (higher account and spend risk).
#ANTIGRAVITY_CREDITS=off
# Adjust how Antigravity advertises remaining credits. Used by:
# open-sse/services/antigravityCredits.ts — accepts forced override strings.
# Default: empty (use upstream-reported credits).
#ANTIGRAVITY_CREDITS=
# Override the path to the Antigravity CLI (agy) token file read by the
# "auto-detect local login" import. Used by:
@@ -925,17 +872,15 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
# WINDSURF_FIREBASE_API_KEY=
# ── Qwen (Alibaba) ──
QWEN_OAUTH_CLIENT_ID=f0304373b74a44d2b584a3fb70ca9e56
# ── Kimi Coding (Moonshot) ──
KIMI_CODING_OAUTH_CLIENT_ID=17e5f671-d194-4dfb-9706-5516cb48c098
# ── GitHub Copilot ──
GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# ── GitHub Enterprise (GHE) Copilot ──
# Optional override for GHE Copilot's OAuth client id. Falls back to the public
# GITHUB_OAUTH_CLIENT_ID default when unset. Used by: src/lib/oauth/constants/oauth.ts.
# GHE_COPILOT_OAUTH_CLIENT_ID=
# ── GitLab Duo ──
# Register an OAuth app at: https://gitlab.com/-/profile/applications
# Set redirect URI to: http://localhost:20128/callback (or your NEXT_PUBLIC_BASE_URL + /callback)
@@ -1040,7 +985,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.219 (external, cli)"
CLAUDE_USER_AGENT="claude-cli/2.1.207 (external, cli)"
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
@@ -1065,6 +1010,7 @@ KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
# Used by: open-sse/executors/kiro.ts
# KIRO_VERIFY_FULL_CRC=false
QODER_USER_AGENT="Qoder-Cli"
QWEN_USER_AGENT="QwenCode/0.19.3 (linux; x64)"
CURSOR_USER_AGENT="Cursor/3.4"
# Override Codex client version sent in headers independently of the
@@ -1094,6 +1040,8 @@ CURSOR_USER_AGENT="Cursor/3.4"
# CLI_COMPAT_KIMI_CODING=1
# CLI_COMPAT_KILOCODE=1
# CLI_COMPAT_CLINE=1
# CLI_COMPAT_QWEN=1
# Or enable for all providers at once:
# CLI_COMPAT_ALL=1
@@ -1189,20 +1137,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OMNIROUTE_GROK_TLS_TIMEOUT_MS=60000
# OMNIROUTE_GROK_TLS_GRACE_MS=10000
# ── 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
# ── Grok web quota fetcher (auth.json override) ──
# Used by: open-sse/services/grokQuotaFetcher.ts — path of the Grok CLI
# auth.json used to fetch the grok-web weekly quota. Defaults to
# ~/.grok/auth.json; override for tests or a non-standard CLI install.
# GROK_AUTH_PATH=
# ── Browser-backed web-cookie chat (Playwright shared pool) ──
# Used by: open-sse/services/browserPool.ts + browserBackedChat.ts. The shared
# browser pool warms a headless context for web-cookie providers (e.g. claude-web)
@@ -1233,13 +1167,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# PIN_DROP_BACKOFF_LEVEL=2
# PIN_DROP_GRACE_MS=20000
# Whether OmniRoute may emit SSE `:` comment lines (e.g. the `: keepalive` heartbeat).
# Some strict OpenAI-compatible clients parse every SSE line as JSON and crash on `:`
# comments. Set to `off` to suppress comment-shaped heartbeats (they become a no-op);
# `data:` heartbeats are unaffected. Default: enabled.
# Used by: open-sse/utils/sseHeartbeat.ts.
# OMNIROUTE_SSE_COMMENTS=off
# ── Stream idle detection ──
# STREAM_IDLE_TIMEOUT_MS=600000 # Max silence between SSE chunks (default: 600000)
# # Extended-thinking models rarely pause >90s.
@@ -1496,12 +1423,6 @@ APP_LOG_TO_FILE=true
# NANOBANANA_POLL_TIMEOUT_MS=120000 # Max wait for job completion (default: 120s)
# NANOBANANA_POLL_INTERVAL_MS=2500 # Poll frequency (default: 2.5s)
# ── 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)
# ── AWS Bedrock (Kiro / Audio) ──
# Region used to construct AWS Bedrock endpoints. Used by:
# src/lib/providers/validation.ts and open-sse/handlers/audioSpeech.ts.
@@ -1647,13 +1568,9 @@ APP_LOG_TO_FILE=true
# Also configurable from Dashboard > Settings > Feature Flags.
# OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK=false
# Rate limit maximum wait time before failing a request (ms). Default: 15000 (15s)
# Rate limit maximum wait time before failing a request (ms). Default: 120000 (2 min)
# Used by: open-sse/services/rateLimitManager.ts
# RATE_LIMIT_MAX_WAIT_MS=15000
# 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
# RATE_LIMIT_MAX_WAIT_MS=120000
# Force the auto-enable rate limit safety net on/off regardless of the persisted
# Dashboard setting. Used by: open-sse/services/rateLimitManager.ts.
@@ -1702,17 +1619,6 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/tokenHealthCheck.ts. Default: 3000.
# HEALTHCHECK_STAGGER_MS=3000
# Randomized jitter range (ms) added on top of HEALTHCHECK_STAGGER_MS between
# provider token healthchecks, to prevent bursting (Issue #1220).
# Used by: src/lib/tokenHealthCheck.ts. Defaults: min=500, max=5000.
# HEALTHCHECK_JITTER_MIN_MS=500
# HEALTHCHECK_JITTER_MAX_MS=5000
# Concurrent-check batch size for the startup token-healthcheck sweep. Larger
# values check more connections in parallel; smaller values reduce burst load.
# Used by: src/lib/tokenHealthCheck.ts. Default: 20 (Issue #7875, regression of #7719).
# HEALTHCHECK_BATCH_SIZE=20
# ═══════════════════════════════════════════════════════════════════════════════
# 22. DEBUGGING
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1851,22 +1757,6 @@ APP_LOG_TO_FILE=true
# for root-less / user-namespaced deployments (e.g. rootless Docker/Podman)
# where the operator trusts the CA manually (e.g. via Node's extra-CA-certs mechanism).
# OMNIROUTE_NO_SUDO=0
# Explicit opt-out: skip provisioning /etc/hosts DNS entries for the Antigravity
# proxy hostnames entirely (containers with no sudo/root available).
# Used by: src/mitm/dns/provision.ts.
# SKIP_ANTIGRAVITY_DNS=true
# Skip writing to the hosts file when adding/removing DNS entries (e.g. sandboxed
# or read-only test environments). Used by: src/mitm/dns/dnsConfig.ts.
# OMNIROUTE_SKIP_DNS_WRITE=1
# Opt in to the root-CA + per-host-leaf cert model for the MITM proxy (#6684).
# Fresh installs and installs with this set to "true" get a persisted root CA that
# signs per-host leaves; installs with a pre-existing trusted legacy leaf keep the
# legacy fixed-SAN cert unless opted in. Used by: src/mitm/manager.ts.
# MITM_ROOT_CA_ENABLED=true
# Set BY the MITM manager for the spawned proxy process ("root-ca" | "legacy") —
# reflects the migration decision above; not meant to be set manually.
# Read by: src/mitm/server.cjs.
# MITM_CERT_MODE=legacy
# ── Test/CI-only guards (never needed in production) ──
# Set automatically by tests/_setup/isolateDataDir.ts and the CI workflows: the
@@ -1886,14 +1776,6 @@ APP_LOG_TO_FILE=true
# 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).
# Used by: src/lib/freeProxyProviders/scheduler.ts
# FREE_PROXY_AUTO_SYNC_ENABLED=true
# Sync interval in ms (default: 1800000 = 30 min).
# FREE_PROXY_AUTO_SYNC_INTERVAL_MS=1800000
# ── Free Proxy Pool (1proxy source) ──
# Used by: src/lib/freeProxyProviders/oneproxy.ts
# Set FREE_PROXY_1PROXY_ENABLED=false to disable this source.
@@ -1950,11 +1832,6 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/db/backup.ts.
# DB_BACKUP_MAX_FILES=20
# DB_BACKUP_RETENTION_DAYS=0
# Tick interval (ms) of the server-side job that executes backup-schedule.json.
# Must stay well under the 1-minute cron granularity; values below 5000 (or
# unparseable) fall back to the 30000 default.
# 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
@@ -2117,15 +1994,6 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# the full (unfiltered) pool with a warning. Source: open-sse/services/autoCombo/virtualFactory.ts
# OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=false
# ─── Auto-Combo chaos panel (broadcast variant) ────────────────────────────
# Tuning for the `auto/*:chaos` variant, which fans a single request out to a
# panel of provider-diverse models. Panel size is clamped to 1..10 (default 5);
# min-panel and the panel hard-timeout fall back to the engine defaults when
# unset. Source: open-sse/services/autoCombo/virtualFactory.ts
# OMNIROUTE_CHAOS_MAX_PANEL=5
# OMNIROUTE_CHAOS_MIN_PANEL=
# OMNIROUTE_CHAOS_PANEL_TIMEOUT_MS=
# ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ───
# Base URL of the OmniRoute instance to query for /v1/models when regenerating
# an opencode.json with accurate limit.context values. Used by:
@@ -2263,51 +2131,3 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# OMNIROUTE_ROTATE_ON_400=false
# OMNIROUTE_ROTATE_400_THRESHOLD=1
# OMNIROUTE_ROTATE_400_WINDOW_SECONDS=120
# ─────────────────────────────────────────────────────────────────────────────
# PromptQL playground provider (Unofficial/Experimental — src/shared/constants/providers/web-cookie.ts)
# Reverse-engineered GraphQL session bridge for prompt.ql.app. All optional —
# defaults point at the public playground endpoints; override only for a
# self-hosted/alternate PromptQL deployment.
# Used by: open-sse/executors/promptql.ts, open-sse/services/usage/promptql.ts
# ─────────────────────────────────────────────────────────────────────────────
# PROMPTQL_GRAPHQL_ENDPOINT=https://data.prompt.ql.app/promptql/playground-v2-hge/v1/graphql
# PROMPTQL_CREDITS_ENDPOINT=https://data.pro.ql.app/v1/graphql
# PROMPTQL_TOKEN_REFRESH_URL=https://auth.pro.ql.app/ddn/project/token
# PROMPTQL_POLL_TIMEOUT_MS=180000
# ─────────────────────────────────────────────────────────────────────────────
# HyperAgent web provider (Unofficial/Experimental — src/shared/constants/providers/web-cookie.ts)
# Reverse-engineered session bridge for hyperagent.com. Optional — defaults
# point at the public billing/usage endpoint; override only for a
# self-hosted/alternate HyperAgent deployment.
# Used by: open-sse/services/usage/hyperagent.ts
# ─────────────────────────────────────────────────────────────────────────────
# HYPERAGENT_USAGE_URL=https://hyperagent.com/api/settings/billing/usage
# ─────────────────────────────────────────────────────────────────────────────
# Browser-login VNC sessions (optional — src/lib/vncSession/manifest.ts)
# Containerized Chromium+VNC used for interactive browser-login credential
# capture via /api/vnc-session. All optional — defaults target the bundled
# `omniroute-vnc-chromium:local` image; override only for a custom image, ports,
# or lifecycle tuning.
# ─────────────────────────────────────────────────────────────────────────────
# OMNIROUTE_VNC_IMAGE=omniroute-vnc-chromium:local
# OMNIROUTE_DOCKER_BIN=docker
# OMNIROUTE_VNC_CONTAINER_VNC_PORT=3000
# OMNIROUTE_VNC_CONTAINER_CDP_PORT=9223
# OMNIROUTE_VNC_CONTAINER_PROFILE_DIR=/config
# OMNIROUTE_VNC_PROFILE_DIR=
# OMNIROUTE_VNC_IDLE_MS=600000
# OMNIROUTE_VNC_MAX_MS=1800000
# OMNIROUTE_VNC_MAX_SESSIONS=4
# OMNIROUTE_VNC_READY_MS=45000
# OMNIROUTE_VNC_HARVEST_MS=20000
# OMNIROUTE_VNC_CHROMIUM_ARGS=--remote-debugging-port=9222 --no-first-run --no-default-browser-check
# ─────────────────────────────────────────────────────────────────────────────
# Data-dir alias (optional — open-sse/services/notionThreadSessions.ts)
# Legacy fallback for DATA_DIR, checked only after DATA_DIR and
# OMNIROUTE_DATA_DIR are both unset. Locates the Notion web-thread session cache.
# ─────────────────────────────────────────────────────────────────────────────
# VIBEPROXY_DATA_DIR=

View File

@@ -1,9 +0,0 @@
# Homologação E2E real — copie para .env.homolog (NUNCA commitar o real)
HOMOLOG_BASE_URL=http://192.168.0.15:20128
# Senha de management do dashboard da VPS (a mesma do /login)
HOMOLOG_ADMIN_PASSWORD=
# Deixe vazio: a suíte cria uma API key efêmera via admin e revoga no fim.
# Só preencha para depurar uma camada isolada com uma key fixa.
HOMOLOG_API_KEY=
# Tier crítico (chat real, max_tokens=5). Demais providers: só validação de catálogo.
HOMOLOG_CRITICAL_PROVIDERS=openai,anthropic,gemini,codex,grok,glm,deepseek,openrouter

View File

@@ -24,15 +24,6 @@ updates:
update-types: ["version-update:semver-major"]
- dependency-name: "eslint-config-next"
update-types: ["version-update:semver-major"]
# typescript majors are peer-blocked by typescript-eslint, which pins a hard
# upper bound (8.64.0 → peerDependencies.typescript ">=4.8.4 <6.1.0"). A TS 7
# bump therefore violates the peer and takes down the whole toolchain at once —
# #7068 grouped it with 6 harmless bumps and turned Build + Lint + Quality Ratchet
# + Unit (6/8, 8/8) + Integration (1/2, 2/2) + dast-smoke red in one shot, blocking
# the innocuous updates riding along with it. Un-ignore once typescript-eslint
# widens the peer, and migrate TS majors intentionally (own PR, own CI run).
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
# jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API).
# scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it
# parses jscpd-report.json against a frozen baseline). A v5 major would break

View File

@@ -9,12 +9,10 @@
## Validation
Run only the focused loop for what you changed — the full unit suite, Vitest, the
60% coverage gate, and the production build all run in CI on this PR (#8329):
- [ ] Focused tests for the change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
- [ ] `npm run lint`
- [ ] Production-code changes include a new or updated automated test in this PR
- [ ] `npm run test:unit`
- [ ] `npm run test:coverage`
- [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches
- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below
## Tests Added Or Updated

View File

@@ -33,13 +33,12 @@ jobs:
docs: ${{ steps.classify.outputs.docs }}
i18n: ${{ steps.classify.outputs.i18n }}
workflow: ${{ steps.classify.outputs.workflow }}
testsOnly: ${{ steps.classify.outputs.testsOnly }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
- id: classify
@@ -82,7 +81,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -125,23 +124,11 @@ jobs:
- run: npm run check:route-guard-membership
- run: npm run check:test-discovery
- run: npm run check:tracked-artifacts
# WS1.7 (v3.8.49 plan): Dockerfile lint (hadolint, pinned by digest).
# failure-threshold=error keeps the 5 pre-existing warnings (DL3008/DL3003/
# DL3016 version pinning / WORKDIR) visible without blocking; any ERROR fails.
- name: hadolint (Dockerfile)
run: docker run --rm -i hadolint/hadolint@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e hadolint --failure-threshold error - < Dockerfile
- run: npm run check:lockfile
- run: npm run check:licenses
# check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the
# husky pre-commit hook; the standalone copy here was redundant (ROI dedup).
- run: npm run typecheck:core
# #7033: typecheck:core's curated file allowlist does not cover
# src/app/(dashboard) TSX (and next.config.mjs sets ignoreBuildErrors:
# true, so `next build` never type-checks it either) — orphaned
# identifiers there (see #6625/#6909) were invisible to CI. This gate
# runs tsc scoped to the dashboard tree against a frozen baseline of
# pre-existing errors; only NEW errors fail it.
- run: npm run check:dashboard-typecheck
# typecheck:noimplicit:core dropped from this job (2026-07 optimize):
# it was advisory (continue-on-error) and largely subsumed by the blocking
# check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core.
@@ -161,7 +148,7 @@ jobs:
# The coverage.* metrics degrade gracefully: the download is continue-on-error and
# the ratchet runs with --allow-missing, so absent coverage is skipped, not failed.
# Path filter: code-only — pure docs/i18n PRs have nothing for these ratchets to guard.
if: ${{ !cancelled() && !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }}
if: ${{ !cancelled() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }}
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
permissions:
@@ -171,7 +158,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -271,7 +258,7 @@ jobs:
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
# Path filter: code-only (scanners/ratchets target production surface).
if: ${{ !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true')) }}
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
steps:
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
@@ -280,7 +267,7 @@ jobs:
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -388,7 +375,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -424,7 +411,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -452,33 +439,13 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
# #8038: cheap single-locale glossary/protected-terms consistency gate —
# complements i18n-ui-coverage (key parity) and the ICU `i18n` job below
# without needing app-boot/Playwright infra. Same gating as i18n-ui-coverage.
i18n-glossary-zhcn:
name: i18n Glossary (zh-CN)
runs-on: ubuntu-latest
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-TW
# D4 (plano mestre testes+CI): a matrix de ~40 jobs de <1min por idioma saturava sozinha
# a concorrência de jobs da conta (Free = 20 slots, compartilhados entre TODOS os repos)
# e pagava spin-up + arredondamento de billing por idioma. Um único job itera os idiomas
@@ -497,7 +464,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Validate all languages
@@ -535,11 +502,11 @@ jobs:
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
- name: Fetch base branch
run: git fetch --no-tags origin "${GITHUB_BASE_REF}"
run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1
- name: Validate source changes include tests
run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md
# Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests.
@@ -574,7 +541,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -622,7 +589,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -642,26 +609,12 @@ jobs:
- name: Assert dist/server.js exists
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
- run: npm run check:pack-artifact
# WS1.2 (#7065 class): pack the real tarball, install it into a clean prefix and
# BOOT it to a healthy /api/monitoring/health — the gate that structure checks
# cannot provide (3 releases shipped boot-crashing tarballs with green lists).
- name: Boot-smoke the packed tarball
run: npm run check:pack-boot
electron-package-smoke:
name: Electron Package Smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
name: Electron Package Smoke
runs-on: ubuntu-latest
timeout-minutes: 25
needs: build
# WS1.5 (v3.8.49 plan): the Electron rebuild/spawn path previously executed for
# the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned
# without shell, CVE-2024-27980 behavior change) could only surface at release.
# windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release
# PR; ubuntu keeps the full pack + headless smoke.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
CSC_IDENTITY_AUTO_DISCOVERY: "false"
@@ -669,7 +622,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -687,21 +640,9 @@ jobs:
working-directory: electron
run: npm install --no-audit --no-fund
- name: Pack Electron app
if: runner.os == 'Linux'
working-directory: electron
run: npm run pack
# ADVISORY while the new Windows leg matures (repo convention, dast-smoke
# precedent): its first-ever real run (2026-07-15, run 29457533565) died in
# 0.7s with the error swallowed by pwsh — bash shell captures stderr and
# continue-on-error keeps the heavy gate green while we harden it (#7336).
- name: Prepare Electron standalone (Windows ABI rebuild + spawn path)
if: runner.os == 'Windows'
working-directory: electron
continue-on-error: true
shell: bash
run: npm run prepare:bundle 2>&1
- name: Smoke packaged Electron app
if: runner.os == 'Linux'
env:
ELECTRON_SMOKE_TIMEOUT_MS: 60000
run: xvfb-run -a npm run electron:smoke:packaged
@@ -730,7 +671,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -761,23 +702,6 @@ jobs:
path: coverage-shard/*.json
if-no-files-found: error
test-bun-sqlite:
name: Bun SQLite Compatibility
runs-on: ubuntu-latest
timeout-minutes: 10
needs: changes
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run test:bun:db
test-vitest:
name: Vitest (MCP / autoCombo / UI components)
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest).
@@ -794,30 +718,19 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
# WS5.2/5.3 (v3.8.49 plan): JUnit output feeds Trunk Flaky Tests (advisory upload
# below). node:test stays OUT of the first wave (fd1-sensitive reporter stream).
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-mcp.xml
# vitest:ui went back to 870/870 green in the v3.8.49 quality plan (WS6.1,
# PR #7127 — 69 fails triaged: matchMedia polyfill, node:testvitest migration,
# CompareTab D22 cap). Promoted to BLOCKING per the plan's post-merge step.
- run: npm run test:vitest:ui -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-ui.xml
# Trunk Flaky Tests upload — advisory (never blocks), own-origin only (fork PRs
# have no TRUNK_TOKEN). Pinned by SHA (tag v2.1.2).
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
- run: npm run test:vitest
# vitest:ui is RED today (14 fails — UI component drift accumulated while the
# suite never ran in CI). Informational until the Fase 6A triage (2026-06-16+)
# fixes the components/tests; then drop continue-on-error to make it blocking.
- run: npm run test:vitest:ui
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: trunk-junit/**/*.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
# Node 24/26 compatibility matrices moved to .github/workflows/nightly-compat.yml
# (plano mestre testes+CI, Eixo D2 — they cost ~28% of every heavy run to catch a
@@ -826,12 +739,9 @@ jobs:
test-coverage:
name: Coverage
runs-on: ubuntu-latest
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
timeout-minutes: 20
timeout-minutes: 10
needs: test-unit
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
if: ${{ !cancelled() && needs.test-unit.result == 'success' }}
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
@@ -839,7 +749,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -879,7 +789,6 @@ jobs:
--merge-async \
--reporter=text-summary \
--reporter=json-summary \
--reporter=lcov \
--exclude=tests/** \
--exclude=**/*.test.* \
--check-coverage \
@@ -901,18 +810,6 @@ jobs:
> coverage/coverage-report.md
fi
cat coverage/coverage-report.md >> "$GITHUB_STEP_SUMMARY"
# WS5.6 (D7, v3.8.49 plan): patch coverage on the PR diff via Codecov —
# informational during calibration (codecov.yml sets informational: true);
# promote to blocking only after ~2 weeks without false blocks. The lcov
# reporter above also fixes coverage/lcov.info being silently absent
# (if-no-files-found: warn) — Sonar consumes the same file.
- name: Upload coverage to Codecov (informational)
if: always()
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Upload coverage artifacts
if: always()
uses: actions/upload-artifact@v7
@@ -937,14 +834,10 @@ jobs:
with:
persist-credentials: false
fetch-depth: 0
# The upload strips the common `coverage/` prefix, so the artifact root holds
# lcov.info directly — download into coverage/ so it lands at coverage/lcov.info,
# where sonar.javascript.lcov.reportPaths expects it (path: . left the Sonar
# new-code coverage at 0% every scan).
- uses: actions/download-artifact@v8
with:
name: coverage-report
path: coverage/
path: .
- name: Explain SonarQube skip
if: ${{ github.event_name != 'pull_request' || env.SONAR_TOKEN == '' || env.SONAR_HOST_URL == '' }}
run: |
@@ -1063,12 +956,7 @@ jobs:
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
# Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners.
timeout-minutes: 45
needs: [build, changes]
# WS3.1 hotfix fast-lane: the 9-shard E2E matrix is the CI critical path (~25min).
# It skips for (a) PRs labeled `hotfix` (entry policy in docs/ops/RELEASE_CHECKLIST.md:
# production-broken only, full-suite evidence from the previous green run linked in the
# PR) and (b) tests-only diffs outside tests/e2e/ (cannot change the served app).
if: ${{ needs.changes.outputs.testsOnly != 'true' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
needs: build
strategy:
fail-fast: false
matrix:
@@ -1082,7 +970,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -1103,33 +991,7 @@ jobs:
- name: Extract Next.js build artifact
run: |
tar -xzf /tmp/e2e-build.tar.gz
# WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json).
# Measured skew of plain --shard was 14× (24m47s vs 1m47s) — E2E was the CI
# critical path. The balancer self-verifies completeness and exits non-zero on
# any inconsistency, falling back to plain --shard (never fewer specs).
- name: Run E2E tests (duration-balanced shard)
env:
SHARD: ${{ matrix.shard }}
PLAYWRIGHT_JUNIT_OUTPUT_NAME: junit-e2e-results.xml
run: |
if FILES=$(node scripts/quality/balance-e2e-shards.mjs "$SHARD" 9); then
if [ -z "$FILES" ]; then echo "[e2e-balance] shard $SHARD has no files"; exit 0; fi
echo "[e2e-balance] shard $SHARD runs:"; echo "$FILES"
# shellcheck disable=SC2086 — FILES is our own newline-separated path list
npx playwright test $(echo "$FILES" | tr '\n' ' ') --reporter=line,junit
else
echo "[e2e-balance] balancer unavailable — plain --shard fallback"
npx playwright test tests/e2e/*.spec.ts --shard="$SHARD"/9 --reporter=line,junit
fi
# WS5.2/5.3: Trunk Flaky Tests upload — advisory, own-origin only, SHA-pinned.
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: junit-e2e-results.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
test-integration:
name: Integration Tests (${{ matrix.shard }}/2)
@@ -1152,7 +1014,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -1175,7 +1037,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -1192,7 +1054,6 @@ jobs:
- lint
- docs-sync-strict
- i18n-ui-coverage
- i18n-glossary-zhcn
- i18n
- pr-test-policy
- build
@@ -1238,7 +1099,6 @@ jobs:
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| i18n Glossary (zh-CN) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -22,10 +22,10 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
- uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
category: "/language:javascript-typescript"

View File

@@ -2,30 +2,15 @@ name: DAST smoke (PR)
on:
pull_request:
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.
paths-ignore:
- "docs/**"
- "**/*.md"
permissions:
contents: read
# Superseded runs on the same PR must not stack 25-minute advisory builds
# (force-push storms were holding 2-3 runners each). Same group rule as quality.yml.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
dast-smoke:
runs-on: ubuntu-latest
# ADVISORY while this new gate matures (repo convention: advisory -> blocking).
# Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs.
continue-on-error: true
# Build CLI bundle alone varies 6-11min on GitHub-hosted runners (3 consecutive
# timeouts observed on 2026-07-14 with the old 12min cap killing schemathesis
# mid-run) — 25min leaves real headroom for the actual DAST steps.
timeout-minutes: 25
timeout-minutes: 12
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
@@ -33,14 +18,12 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Build CLI bundle
env:
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute
env:
@@ -53,7 +36,7 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
sleep 2
done
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- run: pip install schemathesis

View File

@@ -88,7 +88,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm

View File

@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm

View File

@@ -66,7 +66,7 @@ jobs:
with:
ref: ${{ needs.resolve-branch.outputs.target }}
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "26"
cache: npm
@@ -93,7 +93,7 @@ jobs:
with:
ref: ${{ needs.resolve-branch.outputs.target }}
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm

View File

@@ -15,13 +15,11 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with: { node-version: "24", cache: npm }
- run: npm ci
- name: Build CLI bundle
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
run: npm run build:cli
- name: Start OmniRoute (block mode)
env:
@@ -67,16 +65,14 @@ jobs:
with:
persist-credentials: false
if: steps.gate.outputs.run == 'true'
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
if: steps.gate.outputs.run == 'true'
with: { node-version: "24", cache: npm }
- run: npm ci
if: steps.gate.outputs.run == 'true'
- name: Build CLI bundle
if: steps.gate.outputs.run == 'true'
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
run: npm run build:cli
- name: Start OmniRoute
if: steps.gate.outputs.run == 'true'
@@ -90,7 +86,7 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
sleep 2
done
- uses: actions/setup-python@v7
- uses: actions/setup-python@v6
if: steps.gate.outputs.run == 'true'
with: { python-version: "3.12" }
- run: pip install garak

View File

@@ -107,7 +107,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -151,7 +151,7 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: Download all mutation reports

View File

@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm

View File

@@ -1,18 +1,11 @@
name: Release-Green (continuous)
name: Nightly Release-Green
# Solution D — continuous, NON-BLOCKING drift signal for the active release branch.
#
# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds
# accrue silently on release/** and explode — in layers — at release time. This
# workflow reproduces the release-equivalent validation on the release branch and,
# when there are HARD failures, opens/updates a single tracking issue.
#
# WS5.1 (v3.8.49 quality plan) — two modes:
# push to release/v* (code paths) → --quick (fast HARD gates, ~5-8min). Catches the
# captain's direct pushes (sync-back — the one ungated write path) AND the merged
# COMBINATION right after every PR merge, attributing the offending push range in
# the issue. Base-red MTTD drops from ≤24h to ≤~15min after the offending push.
# schedule (3×/day) → full --with-build --full-ci (the deep sweep incl. build+suites).
# nightly reproduces the release-equivalent validation on the active release branch
# HEAD and, when there are HARD failures, opens/updates a single tracking issue.
#
# It is NOT a required status check and never touches a contributor PR — it only
# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is
@@ -21,23 +14,8 @@ name: Release-Green (continuous)
# package-artifact) flip the issue open.
on:
push:
branches: ["release/v*", "main"]
paths:
- "src/**"
- "open-sse/**"
- "bin/**"
- "electron/**"
- "scripts/**"
- "tests/**"
- "config/**"
- "package.json"
- "package-lock.json"
- "tsconfig*.json"
schedule:
- cron: "23 5 * * *" # full sweep — off-peak, distinct from other nightlies
- cron: "23 12 * * *" # full sweep — midday (WS5.1: 3×/day instead of 1×)
- cron: "23 18 * * *" # full sweep — evening
- cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies
workflow_dispatch:
inputs:
branch:
@@ -50,9 +28,7 @@ permissions:
issues: write
concurrency:
# push storms during merge campaigns collapse to the newest commit per branch;
# scheduled full sweeps keep their own single lane.
group: release-green-${{ github.event_name }}-${{ github.ref }}
group: nightly-release-green
cancel-in-progress: true
env:
@@ -61,9 +37,6 @@ env:
jobs:
release-green:
name: Validate active release branch
# On a push, only run for release/* pushes — a push to main is handled by the
# main-green job below. Schedule/dispatch always run (they validate the highest release).
if: ${{ github.event_name != 'push' || startsWith(github.ref_name, 'release/') }}
# Dynamic runner: with USE_VPS_RUNNER=true (release window / on-demand pre-flight)
# this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY,
# no local noauth CLIs => zero machine-specific false positives) and no contention.
@@ -83,15 +56,10 @@ jobs:
id: branch
env:
INPUT_BRANCH: ${{ github.event.inputs.branch }}
EVENT_NAME: ${{ github.event_name }}
PUSHED_REF: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ -n "${INPUT_BRANCH:-}" ]; then
TARGET="$INPUT_BRANCH"
elif [ "$EVENT_NAME" = "push" ]; then
# validate exactly what was pushed, not the highest branch
TARGET="$PUSHED_REF"
else
# highest release/vX.Y.Z by semver among remote branches
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
@@ -116,7 +84,7 @@ jobs:
git checkout "$TARGET"
git log -1 --oneline
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -125,26 +93,16 @@ jobs:
- name: Release-green validation (full)
id: validate
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set +e
# --hermetic: scrub live-test trigger vars (self-hosted runner may carry
# operator env; hosted ignores the unknown flag before #6300 lands).
# push → --quick: fast HARD gates only (~5-8min), per-merge signal.
# schedule/dispatch → --with-build --full-ci: ALSO run every static gate from
# ci.yml's gate jobs (lint, quality-gate, quality-extended, docs-sync-strict,
# pr-test-policy) + build + full suites. PRs into release/** only get the
# fast-gates, so these accrue silently and explode in layers on the release PR
# (v3.8.46: 11 static base-reds leaked).
if [ "$EVENT_NAME" = "push" ]; then
MODE="--quick"
else
MODE="--with-build --full-ci"
fi
echo "[release-green] mode: $MODE (event: $EVENT_NAME)"
# shellcheck disable=SC2086 — MODE is an intentional flag list
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
# --full-ci: ALSO run every static gate from ci.yml's gate jobs (lint,
# quality-gate, quality-extended, docs-sync-strict, pr-test-policy). PRs into
# release/** only get the fast-gates, so these accrue silently and explode in
# layers on the release PR (v3.8.46: 11 static base-reds leaked). Running them
# nightly opens the tracking issue the moment one lands, not at release time.
node scripts/quality/validate-release-green.mjs --json --with-build --hermetic --full-ci \
1> release-green.json 2> release-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
echo "------- report -------"
@@ -156,28 +114,15 @@ jobs:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.event.after }}
run: |
set -euo pipefail
TITLE="🔴 Release branch not green: ${TARGET}"
{
echo "The **release-green** validation found HARD failures on \`${TARGET}\`."
echo "The nightly **release-green** validation found HARD failures on \`${TARGET}\`."
echo "These are real defects that would block the release PR — fix them in the"
echo "originating PR branch (via co-authorship), not by demanding it from contributors."
echo ""
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
# WS5.1 attribution: on push events the offending change IS this push's range
# (one merge per push in the normal queue), so name it — no bisect needed.
if [ "$EVENT_NAME" = "push" ] && [ -n "${BEFORE_SHA:-}" ] && \
git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
echo ""
echo "**Offending push range** (\`${BEFORE_SHA:0:9}..${AFTER_SHA:0:9}\`):"
echo '```'
git log --no-decorate --oneline "${BEFORE_SHA}..${AFTER_SHA}" | head -20
echo '```'
fi
echo "**Run:** ${RUN_URL}"
echo ""
echo '```'
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
@@ -204,100 +149,3 @@ jobs:
release-green.json
release-green.log
if-no-files-found: ignore
# Companion arm for `main`. Under the parallel-cycle model, main only receives merged
# work at the release squash — so a gate/infra fix that lands only on release leaves
# main red the whole cycle, and repo-wide gates (CodeQL alert count, ratchet baselines)
# turn EVERY PR into main red on a check unrelated to its diff. This detects that and
# opens a "🔴 main not green" tracking issue. The PREVENTION is the companion-PR reflex
# (Hard Rule #21 area / _shared/merge-gates.md §8); this is the automated backstop.
main-green:
name: Validate main branch
# On a push, only run for a push to main — a push to release/* is handled by
# release-green above. Schedule/dispatch always run (they also sweep main).
if: ${{ github.event_name != 'push' || github.ref_name == 'main' }}
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Main-green validation
id: validate
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set +e
# push (a merge into main) → --quick fast HARD gates; schedule/dispatch → full sweep.
if [ "$EVENT_NAME" = "push" ]; then
MODE="--quick"
else
MODE="--with-build --full-ci"
fi
echo "[main-green] mode: $MODE (event: $EVENT_NAME)"
# shellcheck disable=SC2086 — MODE is an intentional flag list
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> main-green.json 2> main-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
echo "------- report -------"
cat main-green.log
- name: Open / update tracking issue on HARD failure
if: steps.validate.outputs.exit != '0'
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
TITLE="🔴 main branch not green"
{
echo "The **main-green** validation found HARD failures on \`main\`."
echo ""
echo "Because \`main\` only receives merged work at the release squash, a gate/infra"
echo "fix that landed only on the release branch leaves \`main\` broken for the whole"
echo "cycle — and repo-wide gates (CodeQL alert count, ratchet baselines) then turn"
echo "**every open PR into main** red on a check unrelated to its diff. The fix is a"
echo "companion PR \`--base main\` carrying the release-side fix (see"
echo "\`_shared/merge-gates.md\` §8), NOT chasing each contributor PR."
echo ""
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
echo ""
echo '```'
sed -n '/──────── verdict ────────/,$p' main-green.log || tail -40 main-green.log
echo '```'
echo ""
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) is expected mid-cycle and did NOT, on its own, open this issue._"
} > issue-body.md
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
echo "Updated existing issue #$EXISTING"
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
fi
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: main-green-report
path: |
main-green.json
main-green.log
if-no-files-found: ignore

View File

@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -29,7 +29,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -51,7 +51,6 @@ jobs:
- name: Build CLI bundle
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute (background)
env:
@@ -95,7 +94,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm

View File

@@ -16,13 +16,11 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with: { node-version: "24", cache: npm }
- run: npm ci
- name: Build CLI bundle
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
run: npm run build:cli
- name: Start OmniRoute (background)
env:
@@ -35,7 +33,7 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
sleep 2
done
- uses: actions/setup-python@v7
- uses: actions/setup-python@v6
with: { python-version: "3.12" }
- name: Install schemathesis
run: pip install schemathesis

View File

@@ -22,14 +22,6 @@ on:
- latest
- next
- historic
publish_mode:
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
required: false
default: "staged"
type: choice
options:
- staged
- direct
workflow_call:
inputs:
version:
@@ -71,7 +63,7 @@ jobs:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org
@@ -174,34 +166,8 @@ jobs:
TAG: ${{ github.ref_name }}
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
# real tarball and fails the publish before anything reaches the registry.
- name: Boot-smoke the tarball before ANY publish
- name: Publish to npm
if: steps.resolve.outputs.skip != 'true'
run: npm run check:pack-boot
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
# parks the exact bytes on the registry WITHOUT making them installable; the
# owner then verifies and approves with 2FA (`npm stage approve`), moving the
# human gate to AFTER the proof instead of before it. Requires npm >= 11.15
# (staged publishing GA 2026-05-22). publish_mode=direct is the emergency
# fallback (legacy immediate publish) via workflow_dispatch.
- name: Ensure npm supports staged publishing
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
run: |
set -euo pipefail
CUR=$(npm --version)
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
# Pinned exact version (supply-chain: never float @latest in the publish
# job); bump deliberately when a newer npm is required.
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
npm install -g --ignore-scripts npm@11.15.0
fi
npm --version
- name: Publish to npm (staged — owner approves with 2FA)
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
@@ -209,32 +175,10 @@ jobs:
run: |
set -euo pipefail
# Always pass --tag explicitly. Defense in depth: even if VERSION is
# accidentally an older release, the historic tag will NOT claim `@latest`.
npm stage publish --provenance --access public --tag "$TAG"
{
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
echo ""
echo "The exact bytes are parked on the registry. To release them:"
echo '```'
echo "npm stage list omniroute # find the stage id"
echo "npm stage approve <id> # owner 2FA — THE publish"
echo '```'
echo "To verify the staged bytes first: npm stage download <id> → run"
echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)."
echo "To discard: npm stage reject <id>."
} >> "$GITHUB_STEP_SUMMARY"
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
- name: Publish to npm (DIRECT — emergency fallback)
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
# accidentally an older release, `npm publish --tag historic` will
# NOT promote it to `@latest`.
npm publish --provenance --access public --tag "$TAG"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
- name: Publish to GitHub Packages
if: steps.resolve.outputs.skip != 'true'
@@ -265,7 +209,7 @@ jobs:
# Full history needed for auto-bump: git diff against previous release tag
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org

View File

@@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
@@ -52,7 +52,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm

View File

@@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
@@ -51,7 +51,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "20"
cache: npm

View File

@@ -36,7 +36,7 @@ jobs:
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
- id: classify
@@ -62,13 +62,13 @@ jobs:
docs-gates:
name: Docs Gates (fast-path)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -82,7 +82,7 @@ jobs:
name: Fast Quality Gates
needs: changes
# Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs).
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
@@ -99,7 +99,7 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -143,24 +143,6 @@ jobs:
- run: npm run check:complexity-ratchets
- name: Typecheck (core)
run: npm run typecheck:core
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
- name: Typecheck (dashboard)
run: npm run check:dashboard-typecheck
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
# (the hybrid is the officially documented pattern). Isolated npx on purpose:
# installing an alias package could collide node_modules/.bin/tsc with 6.x.
# Promote to the blocking gate after ~1 week of parity with the step above.
- name: Typecheck (core) — TS7 native shadow (advisory)
continue-on-error: true
run: |
RC=0
START=$(date +%s)
npx -y -p typescript@7 tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
exit $RC
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
# unit tests impacted by this PR's changed files. On hub/unmapped changes the
# selector returns __RUN_ALL__ — full-suite authority is the parallel
@@ -177,26 +159,17 @@ jobs:
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
node scripts/quality/build-test-impact-map.mjs
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
# Shadow evidence (#8084): persist every selection so TIA false negatives can
# be measured against fast-unit's full-suite verdict across releases BEFORE
# any gate authority moves off ordinary PRs. Artifact uploaded below.
printf '%s\n' "$SEL" > tia-selection.txt
if [ -z "$SEL" ]; then
echo "TIA selection: empty (no source/test changes)" >> "$GITHUB_STEP_SUMMARY"
echo "No source/test changes — skipping unit tests"; exit 0
fi
if [ -z "$SEL" ]; then echo "No source/test changes — skipping unit tests"; exit 0; fi
# CI runners are 4-vCPU; run at --test-concurrency=4 (matching the ci.yml unit
# job) rather than test:unit's local-tuned concurrency=20. Oversubscribing the
# runner makes timing-sensitive tests (db-backup, upstream-timeout, ...) flake,
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
# run race-free regardless of concurrency.
if echo "$SEL" | grep -q "__RUN_ALL__"; then
echo "TIA selection: __RUN_ALL__ (fail-safe) — full-suite authority stays with fast-unit" >> "$GITHUB_STEP_SUMMARY"
echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)."
echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)."
exit 0
fi
echo "TIA selection: $(grep -c . tia-selection.txt) impacted test file(s) — full suite still runs in fast-unit (shadow-evidence phase, #8084)" >> "$GITHUB_STEP_SUMMARY"
echo "Running impacted tests:"; echo "$SEL"
mapfile -t FILES <<< "$SEL"
# Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs
@@ -219,21 +192,11 @@ jobs:
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$?
fi
exit $RC
# #8084 shadow evidence: keep the raw selection downloadable so TIA misses can be
# audited against fast-unit failures on the same run (gate moves need this data).
- name: Upload TIA selection (shadow evidence)
if: always()
uses: actions/upload-artifact@v7
with:
name: tia-selection
path: tia-selection.txt
if-no-files-found: ignore
retention-days: 30
fast-vitest:
name: Vitest (fast-path)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
env:
@@ -244,28 +207,17 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
# which is where flaky-detection volume actually comes from (ci.yml's heavy
# jobs only run on the release PR). Advisory upload, own-origin only.
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: trunk-junit/**/*.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
- run: npm run test:vitest
fast-unit:
name: Unit Tests fast-path (${{ matrix.shard }}/4)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
@@ -284,7 +236,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -311,14 +263,14 @@ jobs:
lint-guard:
name: No new ESLint warnings
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
@@ -350,7 +302,7 @@ jobs:
merge-integrity:
name: Merge integrity (changelog + generated skills)
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }}
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
env:
@@ -362,7 +314,7 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm

View File

@@ -26,7 +26,7 @@ jobs:
persist-credentials: false
- name: Run analysis
uses: ossf/scorecard-action@v2.4.4
uses: ossf/scorecard-action@v2.4.3
with:
results_file: results.sarif
results_format: sarif

View File

@@ -6,12 +6,6 @@ on:
branches: ["main"]
permissions:
contents: read
# Cancel superseded PR runs (same rule as quality.yml). No paths filter on purpose:
# p/secrets must keep scanning docs-only diffs too — credentials leak in .md files.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
semgrep:
runs-on: ubuntu-latest

View File

@@ -40,7 +40,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: "24"

12
.gitignore vendored
View File

@@ -72,7 +72,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
!.env.homolog.example
# Provider API keys (never commit)
*.api-key
.nvidia-api-key
@@ -233,7 +232,7 @@ omniroute.md
# mise configuration
mise.toml
_artifacts/ # release-green artifacts
_artifacts/
.claude-flow/
# ESLint file cache (npm run lint --cache / complexity ratchets)
@@ -241,12 +240,5 @@ _artifacts/ # release-green artifacts
.eslintcache-complexity
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
# CI/local quality artifacts (eslint-results.json, etc.)
.artifacts/
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
.env.homolog
tests/homolog/.auth/
tests/homolog/ui/.auth/
homolog-report/
docker-compose.yml.bak

View File

@@ -74,16 +74,3 @@
# '''tests/unit/''',
# ]
#
[[rules]]
# Falsos-positivos comprovados do generic-api-key — zerados em 2026-07-13 (WS6/D3,
# plano v3.8.49). Revisar em v3.9.0. Nenhum é credencial: dois são NOMES DE CAMPO
# de métricas de latência; o terceiro é o valor PÚBLICO de um beta header da API
# da Anthropic (documentado publicamente, não é segredo).
id = "generic-api-key"
[rules.allowlist]
description = "Field names + public Anthropic beta-header value (não são segredos)"
regexes = [
'''latencyP\d{2}Ms''',
'''interleaved-thinking-2025-05-14''',
]

View File

@@ -1,55 +0,0 @@
# Mergify merge queue — WS3.4/D5 of the v3.8.49 quality/velocity master plan.
#
# WHY: ~85-100 active PR authors/month and 300+ PRs/week peaks, all merged by ONE
# identity. The manual merge-train validated batches by hand; this queue automates
# it with batching + automatic batch bisection (a red batch of N costs ~log2(N)
# revalidations instead of N). Mergify Open Source plan: free, unlimited, public repo.
#
# GOVERNANCE (non-negotiable, mirrors CLAUDE.md Hard Rules #21/#22 + the owner's
# pre-merge ⭐ gate):
# • A PR enters the queue ONLY via the `queue` label — applied by the owner (or a
# session acting for the owner) AFTER the pre-merge ⭐ report/decision. The label
# IS the merge approval; Mergify only executes it.
# • During a release-freeze (open issue labeled `release-freeze`), do NOT label PRs
# targeting the frozen branch — the freeze is a human-honored coordination signal
# the queue cannot see. Retarget to the active release/vX+1 first (Hard Rule #21).
# • Never label a PR another session is actively working (Hard Rule #22b).
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
queue_rules:
- name: release
# Any current or future release branch — the reason GitHub's native queue was
# rejected (no wildcard support on personal-account repos).
queue_conditions:
- base~=^release/v\d+\.\d+\.\d+$
- label=queue
- -draft
- -conflict
# "Everything that ran is green, nothing still running, AND the always-on
# anchor check succeeded" — robust to the path-filtered fast-gates (docs-only
# PRs skip code jobs; matrix shard names vary) while never fail-open: a PR with
# zero checks cannot vacuously merge, because `Merge integrity` runs on EVERY
# non-draft PR (quality.yml) and must be an affirmative success. Review approval
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
# approval in this repo's single-maintainer model (see governance header).
merge_conditions:
- "#check-failure=0"
- "#check-pending=0"
- "#check-success>=1"
- check-success=Merge integrity (changelog + generated skills)
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
# don't hold a lone PR hostage waiting for siblings.
batch_size: 10
batch_max_wait_time: 5 min
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
merge_method: squash
pull_request_rules:
- name: clean up the queue label after merge
conditions:
- merged
actions:
label:
remove:
- queue

View File

@@ -3,9 +3,3 @@ docs/reference/ENVIRONMENT.md
# Dense auto-generated free-tier budget rows (one object per line) — prettier multi-line expand blows past file-size cap 800.
open-sse/config/freeModelCatalog.data.ts
# Generated by scripts/skills/generate-agent-skills.mjs; the generator is their
# formatter of record and check:agent-skills-sync diffs its output byte-for-byte.
# Prettier reformats the frontmatter (blank line after ---), which makes the gate
# fail on any skill that happens to pass through lint-staged.
skills/*/SKILL.md

View File

@@ -10,8 +10,7 @@ This plugin solves that by:
- Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions
- Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute`
- Re-fetching on a configurable TTL (default 5 min) **and** background auto-discovery while OpenCode is running (`autoSyncIntervalMs`, default 5 min), so new models / combo changes appear without restarting OpenCode
- Exposing a force-refresh path (`omniroute_sync_models` tool + `/omni-sync` command template) equivalent to Pi `/omni sync`
- Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode
- Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation)
- **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138)
@@ -74,9 +73,6 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",
// Background re-discovery while OpenCode is running (Pi parity).
// Default 300000 (5 min). Minimum 60000. Set 0 to disable.
"autoSyncIntervalMs": 300000,
},
],
],
@@ -92,27 +88,6 @@ opencode auth login --provider omniroute
Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis.
### Live catalog refresh (auto + force)
While OpenCode is running, the plugin keeps the model catalog fresh in two ways:
| Mechanism | Default | What it does |
| --- | --- | --- |
| `modelCacheTtl` | `300000` (5 min) | On-demand TTL: next provider/models hook after expiry re-fetches `/v1/models` |
| `autoSyncIntervalMs` | `300000` (5 min) | Background timer: proactively invalidates + re-fetches while the harness is running. Min `60000`. Set `0` to disable background polling (TTL still applies) |
**Force sync now** (Pi `/omni sync` equivalent) — OpenCode has no Pi-style slash-command registration API, so the plugin wires both a tool and command templates:
1. **Tool:** `omniroute_sync_models` — invalidates in-memory + disk caches, re-fetches `GET /v1/models` (and combos/enrichment when enabled), returns `{ ok, count, ... }`.
2. **Command templates** (type these in OpenCode):
- `/omni-sync` — asks the agent to call `omniroute_sync_models` and report the result
- `/omni-autosync` — asks the agent to report current `autoSyncIntervalMs` / `modelCacheTtl` status
```text
/omni-sync
/omni-autosync
```
## Multi-instance (prod + preprod side-by-side)
> ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load.
@@ -190,7 +165,7 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
| Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` |
| Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` |
| Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` + `config` |
| `combo/<slug>` namespace + `Combo:` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
| `combo/<slug>` namespace + `Combo: ` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
| Nice names + cost | `/api/pricing/models` display names AND `/api/pricing` per-million-token cost overlaid onto the live catalog | both hooks |
| Canonical-twin dedup + alias-fallback | `/v1/models` exposes the same upstream model under both short alias (`cc/claude-opus-4-7`) and canonical name (`claude/claude-opus-4-7`); the plugin drops the canonical twin when an alias twin exists (no duplicate rows in the picker) and reverse-maps canonical → alias to pick up enrichment for short aliases (`dg/nova-3 → Deepgram - Nova 3`) that `/api/pricing/models` only indexes by canonical | both hooks |
| Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra | both hooks |
@@ -204,33 +179,30 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
## Plugin options
| Option | Type | Default | Description |
| --------------------- | -------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
| `managementReadToken` | `string` | falls back to `apiKey` | Optional read-only token for management catalog GETs; `/v1` inference stays on the connected `apiKey` |
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
For least-privilege deployments, set top-level `managementReadToken` to a read-only management token. It is sent only to catalog reads (`/api/combos`, `/api/combos/auto`, `/api/pricing/models`, `/api/pricing`, `/api/context/combos`, and `/api/providers`). Inference requests under `/v1`, including chat, continue to use the `apiKey` stored by OpenCode. `features.mcpToken` remains independent. If `managementReadToken` is omitted, catalog reads retain the previous `apiKey` behavior.
| Option | Type | Default | Description |
| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- |
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
### `features` block
Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change.
| Feature | Type | Default | What it does |
| --------------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached``cacheRead`, `cache_creation``cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models``GHM`, `Gemini``GEMINI`). Idempotent. Combos intentionally skipped (the `Combo:` prefix already conveys multi-upstream). |
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
| Feature | Type | Default | What it does |
| --------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached``cacheRead`, `cache_creation``cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models``GHM`, `Gemini``GEMINI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). |
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
#### Example — enrichment + compression tags + MCP auto-emit
@@ -242,7 +214,6 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",
"managementReadToken": "<read-only-management-token>",
"features": {
"combos": true,
"enrichment": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/opencode-plugin",
"version": "0.2.1",
"version": "0.2.0",
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
"type": "module",
"main": "./dist/index.js",
@@ -23,7 +23,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [

View File

@@ -46,7 +46,7 @@
*/
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -54,7 +54,6 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { z } from "zod";
import { logger as _logger, setLogLevel, type LogLevel as _LogLevel } from "./logger.js";
@@ -89,17 +88,9 @@ import {
* from providerId.
* - `modelCacheTtl` `/v1/models` TTL cache duration in milliseconds.
* Default: 300_000 (5 min).
* - `autoSyncIntervalMs` Background catalog re-discovery while OpenCode is
* running. Default: 300_000 (5 min). Minimum: 60_000.
* Set `0` to disable background auto-sync (TTL on-demand
* discovery still applies via `modelCacheTtl`).
* - `baseURL` Override base URL for this OmniRoute instance. When
* absent, the loader falls back to a credential-attached
* baseURL set by `/connect`.
* - `managementReadToken` Optional read-only management-plane bearer token.
* Used only for catalog GETs under `/api/*`; `/v1/*`
* inference continues to use the connected `apiKey`.
* Falls back to `apiKey` when unset.
*/
/**
* Optional feature toggles. Every field is opt-in/out per call; defaults
@@ -131,9 +122,8 @@ import {
* provider's API key (from auth.json) when unset.
* Useful when a narrower-scoped MCP-only key is
* preferred over the chat/inference key.
* - `fetchInterceptor` Inject Authorization: Bearer + Content-Type only
* on same-origin `/v1/chat/completions` and
* `/v1/models` requests. Default true.
* - `fetchInterceptor` Inject Authorization: Bearer + Content-Type on
* every outbound request to baseURL. Default true.
* - `debugLog` Capture every outbound request + response to a
* JSONL file at
* `~/.local/share/opencode/plugins/omniroute-debug-{providerId}.jsonl`.
@@ -195,14 +185,7 @@ const optionsSchema = z
.optional(),
displayName: z.string().min(1).optional(),
modelCacheTtl: z.number().positive().optional(),
/**
* Background auto-discovery interval while the harness is running.
* `0` disables background polling. Values in (0, 60000) are clamped up
* to 60000. Default when unset: 300000.
*/
autoSyncIntervalMs: z.number().int().nonnegative().optional(),
baseURL: z.string().url().optional(),
managementReadToken: z.string().min(1).optional(),
features: featuresSchema.optional(),
})
.strict();
@@ -228,29 +211,6 @@ export const PLUGIN_GIT_HASH: string =
export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const;
/** Default background auto-discovery interval (matches modelCacheTtl default). */
export const DEFAULT_AUTO_SYNC_INTERVAL_MS = 300_000 as const;
/** Minimum positive background auto-discovery interval. */
export const MIN_AUTO_SYNC_INTERVAL_MS = 60_000 as const;
/**
* Sanitize background auto-sync interval.
* - unset/invalid → default 300_000
* - explicit 0 → disabled
* - (0, 60000) → clamped to 60000
* - ≥ 60000 → kept as integer ms
*/
export function sanitizeAutoSyncIntervalMs(value: unknown): number {
if (value === undefined || value === null) return DEFAULT_AUTO_SYNC_INTERVAL_MS;
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_AUTO_SYNC_INTERVAL_MS;
const n = Math.trunc(value);
if (n === 0) return 0;
if (n < 0) return DEFAULT_AUTO_SYNC_INTERVAL_MS;
if (n < MIN_AUTO_SYNC_INTERVAL_MS) return MIN_AUTO_SYNC_INTERVAL_MS;
return n;
}
// Manual trim helpers avoid polynomial-regex CodeQL warnings on
// user-supplied baseURL strings (string.replace(/\/+$/, "")). The same
// behaviour, no backtracking.
@@ -276,8 +236,10 @@ function trimLeadingDashes(value: string): string {
* applying defaults. Centralises the providerId fallback so every hook
* sees a consistent identifier.
*/
export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Required<
Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl" | "autoSyncIntervalMs">
export function resolveOmniRoutePluginOptions(
opts?: OmniRoutePluginOptions
): Required<
Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">
> & {
/**
* #6859: the UNPREFIXED provider id ("omniroute", "omniroute-preprod", …).
@@ -293,7 +255,7 @@ export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Re
* lookup fails with "No credentials for opencode-<x>".
*/
omnirouteProviderId: string;
} & Pick<OmniRoutePluginOptions, "baseURL" | "managementReadToken" | "features"> {
} & Pick<OmniRoutePluginOptions, "baseURL" | "features"> {
const rawProviderId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY;
const omnirouteProviderId = trimLeadingOpencodePrefix(rawProviderId);
// OC 1.17.8+ native-adapter gate rejects providerID not in
@@ -311,22 +273,16 @@ export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Re
typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0
? opts.modelCacheTtl
: DEFAULT_MODEL_CACHE_TTL_MS;
const autoSyncIntervalMs = sanitizeAutoSyncIntervalMs(opts?.autoSyncIntervalMs);
return {
providerId,
omnirouteProviderId,
displayName,
modelCacheTtl,
autoSyncIntervalMs,
baseURL: opts?.baseURL,
managementReadToken: opts?.managementReadToken,
features: opts?.features,
};
}
/** Fully resolved plugin options (defaults applied). */
export type ResolvedOmniRoutePluginOptions = ReturnType<typeof resolveOmniRoutePluginOptions>;
/**
* Strip a leading "opencode-" prefix (added only for the OC native-adapter
* gate — see `resolveOmniRoutePluginOptions`) so the returned id is safe to
@@ -336,9 +292,7 @@ export type ResolvedOmniRoutePluginOptions = ReturnType<typeof resolveOmniRouteP
* idempotent-prefix handling above.
*/
function trimLeadingOpencodePrefix(rawProviderId: string): string {
return rawProviderId.startsWith("opencode-")
? rawProviderId.slice("opencode-".length)
: rawProviderId;
return rawProviderId.startsWith("opencode-") ? rawProviderId.slice("opencode-".length) : rawProviderId;
}
/**
@@ -562,378 +516,8 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook
* opencode.json), NOT as a closure binding. Multi-instance support follows
* from each plugin tuple invoking the factory with its own opts.
*/
/**
* Invalidate in-memory fetch cache entries for a baseURL (all credential keys).
* Returns number of entries removed.
*/
export function invalidateOmniRouteFetchCache(
cache: OmniRouteFetchCache,
baseURL?: string,
): number {
if (!baseURL) {
const n = cache.size;
cache.clear();
return n;
}
const prefix = `${baseURL}::`;
let removed = 0;
for (const key of [...cache.keys()]) {
if (key.startsWith(prefix) || key === baseURL) {
cache.delete(key);
removed += 1;
}
}
return removed;
}
/**
* Resolve API credentials for force-sync / background refresh without
* depending on the provider.models auth context.
*/
export async function resolveOmniRouteRuntimeAuth(
resolved: ResolvedOmniRoutePluginOptions,
readAuthJson?: OmniRouteReadAuthJson,
): Promise<{ apiKey: string; baseURL: string; managementReadToken: string } | null> {
const reader = readAuthJson ?? defaultReadAuthJson;
let authJson: AuthJsonShape | undefined | null;
try {
authJson = await reader();
} catch {
authJson = undefined;
}
if (authJson === null) authJson = undefined;
const bareKey = resolved.providerId.startsWith("opencode-")
? resolved.providerId.slice("opencode-".length)
: resolved.providerId;
const lookupKeys = [resolved.providerId];
if (bareKey !== resolved.providerId) lookupKeys.push(bareKey);
if (resolved.omnirouteProviderId && !lookupKeys.includes(resolved.omnirouteProviderId)) {
lookupKeys.push(resolved.omnirouteProviderId);
}
let entry: AuthJsonApiEntry | undefined;
for (const k of lookupKeys) {
const e = authJson?.[k];
if (
e &&
(e as { type?: unknown }).type === "api" &&
typeof (e as { key?: unknown }).key === "string" &&
((e as { key: string }).key).length > 0
) {
entry = e as AuthJsonApiEntry;
break;
}
}
const apiKey = entry?.key ?? "";
if (!apiKey) return null;
const authBaseURL =
entry && typeof (entry as { baseURL?: unknown }).baseURL === "string"
? (entry as { baseURL: string }).baseURL
: "";
const baseURL = resolved.baseURL ?? (authBaseURL || "");
if (!baseURL) return null;
const managementReadToken = resolved.managementReadToken ?? apiKey;
return { apiKey, baseURL, managementReadToken };
}
/**
* Force-refresh OmniRoute catalog: clear memory + disk cache, re-fetch /v1/models
* (and optional management endpoints), and repopulate the shared cache.
* OpenCode equivalent of Pi `/omni sync`.
*/
export async function forceSyncOmniRouteModels(args: {
resolved: ResolvedOmniRoutePluginOptions;
cache: OmniRouteFetchCache;
readAuthJson?: OmniRouteReadAuthJson;
fetcher?: OmniRouteModelsFetcher;
combosFetcher?: OmniRouteCombosFetcher;
autoCombosFetcher?: OmniRouteAutoCombosFetcher;
enrichmentFetcher?: OmniRouteEnrichmentFetcher;
compressionMetaFetcher?: OmniRouteCompressionMetaFetcher;
providersFetcher?: OmniRouteProvidersFetcher;
now?: () => number;
}): Promise<{
ok: boolean;
count: number;
combos: number;
provider: string;
baseURL?: string;
clearedMemory: number;
clearedDisk: boolean;
error?: string;
}> {
const resolved = args.resolved;
const cache = args.cache;
const now = args.now ?? Date.now;
const fetcher = args.fetcher ?? defaultOmniRouteModelsFetcher;
const combosFetcher = args.combosFetcher ?? defaultOmniRouteCombosFetcher;
const autoCombosFetcher = args.autoCombosFetcher ?? defaultOmniRouteAutoCombosFetcher;
const enrichmentFetcher = args.enrichmentFetcher ?? defaultOmniRouteEnrichmentFetcher;
const compressionMetaFetcher =
args.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
const providersFetcher = args.providersFetcher ?? defaultOmniRouteProvidersFetcher;
const features = resolved.features ?? {};
const wantCombos = features.combos !== false;
const wantAutoCombos = features.autoCombos !== false;
const wantEnrichment = features.enrichment !== false;
const wantCompressionMeta = features.compressionMetadata === true;
const wantUsableOnly = features.usableOnly === true;
const wantDiskCache = features.diskCache !== false;
const auth = await resolveOmniRouteRuntimeAuth(
resolved,
args.readAuthJson ?? defaultReadAuthJson,
);
if (!auth) {
return {
ok: false,
count: 0,
combos: 0,
provider: resolved.omnirouteProviderId,
clearedMemory: 0,
clearedDisk: false,
error:
"No OmniRoute credentials/baseURL available. Run `opencode connect omniroute` or set plugin baseURL.",
};
}
const clearedMemory = invalidateOmniRouteFetchCache(cache, auth.baseURL);
// Clear residual entries from prior baseURL history as well.
const clearedAll = invalidateOmniRouteFetchCache(cache);
let clearedDisk = false;
if (wantDiskCache) {
clearedDisk = await clearDiskSnapshot(resolved.providerId);
if (resolved.omnirouteProviderId !== resolved.providerId) {
clearedDisk = (await clearDiskSnapshot(resolved.omnirouteProviderId)) || clearedDisk;
}
}
try {
const rawModels = await fetcher(auth.baseURL, auth.apiKey, 10_000);
let rawCombos: OmniRouteRawCombo[] = [];
if (wantCombos) {
try {
rawCombos = await combosFetcher(auth.baseURL, auth.managementReadToken, 10_000);
} catch (err) {
console.warn("[omniroute-plugin] force sync: combos fetch failed", err);
}
}
let rawAutoCombos: OmniRouteRawAutoCombo[] = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(auth.baseURL, auth.managementReadToken, 5_000);
} catch {
/* soft-fail */
}
}
let rawEnrichment: OmniRouteEnrichmentMap = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(auth.baseURL, auth.managementReadToken, 10_000);
} catch {
rawEnrichment = new Map();
}
}
let rawCompressionCombos: OmniRouteCompressionCombo[] = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(
auth.baseURL,
auth.managementReadToken,
10_000,
);
} catch {
rawCompressionCombos = [];
}
}
let rawConnections: OmniRouteProviderConnection[] = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(auth.baseURL, auth.managementReadToken, 10_000);
} catch {
rawConnections = [];
}
}
const t = now();
const entry = {
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
expiresAt: t + resolved.modelCacheTtl,
};
const cacheKey = modelsCacheKey(
auth.baseURL,
`${auth.apiKey}\0${auth.managementReadToken}`,
);
cache.set(cacheKey, entry);
if (wantDiskCache) {
try {
const fingerprint = diskSnapshotIdentityFingerprint(
auth.baseURL,
auth.apiKey,
auth.managementReadToken,
);
const { expiresAt: _expiresAt, ...diskEntry } = entry;
await defaultDiskSnapshotWriter(resolved.providerId, diskEntry, fingerprint);
} catch {
/* soft-fail disk write */
}
}
console.warn(
`[omniroute-plugin] force sync ok providerId=${resolved.providerId} ` +
`models=${rawModels.length} combos=${rawCombos.length} ` +
`clearedMemory=${clearedMemory + clearedAll} disk=${clearedDisk}`,
);
return {
ok: true,
count: rawModels.length,
combos: rawCombos.length,
provider: resolved.omnirouteProviderId,
baseURL: auth.baseURL,
clearedMemory: clearedMemory + clearedAll,
clearedDisk,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
count: 0,
combos: 0,
provider: resolved.omnirouteProviderId,
baseURL: auth.baseURL,
clearedMemory: clearedMemory + clearedAll,
clearedDisk,
error: message,
};
}
}
export function createOmniRouteSyncModelsTool(args: {
resolved: ResolvedOmniRoutePluginOptions;
cache: OmniRouteFetchCache;
}): ReturnType<typeof tool> {
const { resolved, cache } = args;
return tool({
description:
"Force-refresh the OmniRoute model catalog (OpenCode equivalent of Pi `/omni sync`). " +
"Invalidates in-memory and disk caches, then re-fetches GET /v1/models (and combos when enabled).",
args: {
reason: tool.schema
.string()
.optional()
.describe("Optional reason for the sync (logging only)"),
},
async execute(toolArgs) {
const result = await forceSyncOmniRouteModels({ resolved, cache });
const reason = toolArgs.reason ? ` reason=${toolArgs.reason}` : "";
if (!result.ok) {
return {
title: "OmniRoute sync failed",
output:
`OmniRoute model sync failed for provider=${result.provider}.${reason}\n` +
`${result.error ?? "unknown error"}`,
metadata: result,
};
}
return {
title: `OmniRoute sync: ${result.count} models`,
output:
`OmniRoute models synced.` +
`\nprovider: ${result.provider}` +
`\nbaseURL: ${result.baseURL}` +
`\nmodels: ${result.count}` +
`\ncombos: ${result.combos}` +
`\nclearedMemoryEntries: ${result.clearedMemory}` +
`\nclearedDiskSnapshot: ${result.clearedDisk}` +
`\nTTL: ${resolved.modelCacheTtl}ms` +
`\nautoSyncIntervalMs: ${resolved.autoSyncIntervalMs}` +
reason,
metadata: result,
};
},
});
}
/**
* Start background auto-discovery while the harness is running.
* Quiet: only logs when the model count changes or on errors.
* Returns a stop function.
*/
export function startOmniRouteAutoSync(args: {
resolved: ResolvedOmniRoutePluginOptions;
cache: OmniRouteFetchCache;
intervalMs?: number;
}): () => void {
const resolved = args.resolved;
const cache = args.cache;
const intervalMs = args.intervalMs ?? resolved.autoSyncIntervalMs;
if (!intervalMs || intervalMs <= 0) {
return () => {};
}
let stopped = false;
let inFlight: Promise<void> | null = null;
let lastCount: number | undefined;
const tick = () => {
if (stopped) return;
if (inFlight) return;
inFlight = (async () => {
const result = await forceSyncOmniRouteModels({ resolved, cache });
if (!result.ok) {
console.warn(
`[omniroute-plugin] auto-sync failed providerId=${resolved.providerId}: ${result.error}`,
);
return;
}
if (lastCount === undefined) {
lastCount = result.count;
return;
}
if (result.count !== lastCount) {
console.warn(
`[omniroute-plugin] auto-sync catalog size changed ${lastCount}${result.count} ` +
`(providerId=${resolved.providerId})`,
);
lastCount = result.count;
}
})()
.catch((err) => {
console.warn("[omniroute-plugin] auto-sync tick error", err);
})
.finally(() => {
inFlight = null;
});
};
// Delay first background tick by one interval so session start is not doubled
// with the normal provider.models cold fetch. Manual tool remains immediate.
const timer = setInterval(tick, intervalMs);
if (typeof timer === "object" && timer && "unref" in timer && typeof timer.unref === "function") {
timer.unref();
}
console.warn(
`[omniroute-plugin] auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`,
);
return () => {
stopped = true;
clearInterval(timer);
};
}
export const OmniRoutePlugin: Plugin = async (_input, options) => {
const resolved = resolveOmniRoutePluginOptions(coercePluginOptions(options));
const resolved = coercePluginOptions(options);
// T-07: a single per-plugin-instance cache shared between the provider
// hook (T-03/T-05) and the config-shim hook (T-07). On OC ≥1.14.49 both
// hooks fire within the same Plugin invocation, so a shared cache keeps
@@ -962,53 +546,10 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
// Wire log level: startupDebug:true → "debug", explicit logLevel wins.
setLogLevel(resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn"));
// Background auto-discovery while the harness is running (Pi parity).
// Interval 0 disables. TTL on-demand discovery still works via modelCacheTtl.
startOmniRouteAutoSync({ resolved, cache: sharedCache });
const syncTool = createOmniRouteSyncModelsTool({ resolved, cache: sharedCache });
const bareProviderId = resolved.omnirouteProviderId;
// Config hook: keep existing catalog shim, and register slash command
// templates that ask the agent to call the force-sync tool (OpenCode has no
// Pi-style registerCommand API; tools + command templates are the native path).
const baseConfigHook = createOmniRouteConfigHook(resolved, { cache: sharedCache });
const configWithSyncCommand = async (input: Config) => {
await baseConfigHook(input);
const cfg = input as Config & {
command?: Record<
string,
{ template: string; description?: string; agent?: string; model?: string; subtask?: boolean }
>;
};
if (!cfg.command) cfg.command = {};
if (!cfg.command["omni-sync"]) {
cfg.command["omni-sync"] = {
description: "Force-refresh OmniRoute model catalog (like Pi /omni sync)",
template:
`Force-refresh the OmniRoute model catalog now using the omniroute_sync_models tool ` +
`(provider ${bareProviderId}). After the tool returns, briefly report model count and whether the sync succeeded.`,
};
}
if (!cfg.command["omni-autosync"]) {
cfg.command["omni-autosync"] = {
description: "Show OmniRoute auto-sync / cache status",
template:
`Report OmniRoute discovery status for provider ${bareProviderId}: ` +
`autoSyncIntervalMs=${resolved.autoSyncIntervalMs}, modelCacheTtl=${resolved.modelCacheTtl}. ` +
`If the user asked to refresh now, call omniroute_sync_models.`,
};
}
};
return {
auth: createOmniRouteAuthHook(resolved),
provider: createOmniRouteProviderHook(resolved, { cache: sharedCache }),
config: configWithSyncCommand,
tool: {
omniroute_sync_models: syncTool,
},
config: createOmniRouteConfigHook(resolved, { cache: sharedCache }),
};
};
@@ -1314,14 +855,6 @@ export interface OmniRouteRawCombo {
isHidden?: boolean;
/** When OmniRoute attaches a lifecycle hint we forward it; today it doesn't. */
release_date?: string;
/**
* Server-computed context window for this combo (aggregated from member
* models using the same logic as /v1/models). When present, the client
* uses this value directly instead of re-aggregating from member models.
*
* Added in 3.9.x — old servers do not send it.
*/
computed_context_length?: number;
}
/**
@@ -1526,12 +1059,7 @@ export function mapComboToModelV2(
cache: { read: 0, write: 0 },
},
limit: {
context:
typeof combo.computed_context_length === "number" && combo.computed_context_length > 0
? combo.computed_context_length
: contextValues.length > 0
? Math.min(...contextValues)
: 0,
context: contextValues.length > 0 ? Math.min(...contextValues) : 0,
...(everyDeclaresInput ? { input: Math.min(...inputValues) } : {}),
output: outputValues.length > 0 ? Math.min(...outputValues) : 0,
},
@@ -2807,14 +2335,12 @@ export function buildComboKey(
}
/**
* Internal cache key: `${baseURL}::sha256(credentialId)`. The credential id
* combines the inference key and effective management-read token so catalog
* results fetched under different permissions never share an entry. We hash
* it so the cache key is safe to inspect without leaking either secret.
* Different credential tuples MUST keep independent cache entries: a single
* OC user may register prod + preprod OmniRoute side-by-side with distinct
* keys, and serving one's catalog from the other's cache would be a
* correctness bug, not just a privacy one.
* Internal cache key: `${baseURL}::sha256(apiKey)`. We hash the apiKey so
* the key is safe to log / inspect via debugger without leaking the secret.
* Different (baseURL, apiKey) tuples MUST keep independent cache entries:
* a single OC user may register prod + preprod OmniRoute side-by-side with
* distinct keys, and serving one's catalog from the other's cache would be
* a correctness bug, not just a privacy one.
*/
// codeql[js/insufficient-password-hash]: the input here is an API-key
// identifier we use solely to derive an in-memory cache lookup key — it is
@@ -2965,9 +2491,6 @@ export function createOmniRouteProviderHook(
return {};
}
const apiKey = (auth as { key: string }).key;
// Management-plane catalog reads may use a narrower read-only token.
// Backward compatibility: when unset, retain the historical apiKey use.
const managementReadToken = resolved.managementReadToken ?? apiKey;
// baseURL resolution: plugin opts first, then credential-attached
// baseURL (auth backends sometimes stash it next to the key), then the
@@ -2997,7 +2520,7 @@ export function createOmniRouteProviderHook(
return {};
}
const cacheKey = modelsCacheKey(baseURL, `${apiKey}\0${managementReadToken}`);
const cacheKey = modelsCacheKey(baseURL, apiKey);
const t = now();
const cached = cache.get(cacheKey);
@@ -3029,7 +2552,7 @@ export function createOmniRouteProviderHook(
rawCombos = [];
if (wantCombos) {
try {
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
rawCombos = await combosFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] combos fetch failed, falling back to models-only catalog",
@@ -3044,7 +2567,7 @@ export function createOmniRouteProviderHook(
rawAutoCombos = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
rawAutoCombos = await autoCombosFetcher(baseURL, apiKey, 5_000);
} catch {
// Already handled inside the default fetcher — this catch
// is belt-and-suspenders for injected stubs.
@@ -3056,7 +2579,7 @@ export function createOmniRouteProviderHook(
rawEnrichment = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] enrichment fetch failed, falling back to raw ids",
@@ -3070,11 +2593,7 @@ export function createOmniRouteProviderHook(
rawCompressionCombos = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(
baseURL,
managementReadToken,
10_000
);
rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn("[omniroute-plugin] compression-metadata fetch failed", err);
}
@@ -3088,7 +2607,7 @@ export function createOmniRouteProviderHook(
rawConnections = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
rawConnections = await providersFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] /api/providers fetch failed; usableOnly filter disabled for this refresh",
@@ -3476,18 +2995,17 @@ export function createOmniRouteProviderHook(
}
// ────────────────────────────────────────────────────────────────────────────
// Fetch interceptor (T-04) — Bearer + Content-Type injection on intended
// same-origin OmniRoute inference requests only
// Fetch interceptor (T-04) — Bearer + Content-Type injection on outbound
// provider requests targeting the configured OmniRoute baseURL
// ────────────────────────────────────────────────────────────────────────────
/**
* Build a `fetch`-compatible interceptor that injects `Authorization: Bearer`
* (and a default `Content-Type`) only onto same-origin requests targeting
* `<base>/chat/completions` or `<base>/models`, where `<base>` is the
* configured baseURL path normalized to end in `/v1`. Management, MCP, and
* unrelated inference paths pass through untouched. The apiKey is treated as
* a secret bound to both the configured OmniRoute origin and these intended
* inference endpoints, and MUST NOT leak elsewhere.
* (and a default `Content-Type`) onto outbound requests targeting the given
* `baseURL`. Requests to any other host pass through untouched — the apiKey
* is treated as a secret bound to the configured OmniRoute instance and
* MUST NOT leak to third-party endpoints (a vector AI-SDKs occasionally
* exercise when a tool call rewrites the URL mid-flight).
*
* Ported from Alph4d0g's `opencode-omniroute-auth@1.2.1` `createFetchInterceptor`
* (their `dist/src/plugin.js:477-516`) with these intentional deviations:
@@ -3514,35 +3032,17 @@ export function createOmniRouteFetchInterceptor(config: {
apiKey: string;
baseURL: string;
}): typeof fetch {
let baseOrigin: string | undefined;
const inferencePaths = new Set<string>();
try {
const baseUrl = new URL(config.baseURL);
baseOrigin = baseUrl.origin;
const basePath = ensureV1Suffix(baseUrl.pathname);
inferencePaths.add(`${basePath}/chat/completions`);
inferencePaths.add(`${basePath}/models`);
} catch {
// Credential-attached base URLs are not schema-validated. A malformed
// value must disable injection rather than broaden the credential scope.
}
const trimmed = trimTrailingSlashes(config.baseURL);
// Use `<base>/` for prefix matching to prevent suffix-spoof attacks
// (e.g. baseURL `https://or.example.com/v1` should NOT match
// `https://or.example.com/v1-attacker.evil/...`).
const prefix = `${trimmed}/`;
return async (input, init = {}) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
let requestUrl: URL | undefined;
try {
requestUrl = new URL(url);
} catch {
// Native fetch will report malformed/relative URLs as usual. We only
// decline to attach credentials before forwarding the original input.
}
const normalizedPath = requestUrl ? trimTrailingSlashes(requestUrl.pathname) || "/" : undefined;
const targetsInference =
requestUrl?.origin === baseOrigin &&
normalizedPath !== undefined &&
inferencePaths.has(normalizedPath);
if (!targetsInference) {
const targetsOmniRoute = url === trimmed || url.startsWith(prefix);
if (!targetsOmniRoute) {
return fetch(input, init);
}
@@ -4391,16 +3891,7 @@ export function buildStaticProviderEntry(
// `combo/MASTER` as provider=`combo`. Slug collisions across
// combos are disambiguated with a short UUID-prefix suffix; see
// `buildComboKey` for the policy.
// #6859: server-facing key — NOT the OC-gate-prefixed `opts.providerId`.
// OC dispatches the static-catalog `models` map key VERBATIM as the
// `model` field of the outbound `@ai-sdk/openai-compatible` request
// (only the top-level `provider["<id>"]` segment is stripped for
// routing) — so a bare-slug combo key prefixed with the OC-gated
// `opts.providerId` reaches OmniRoute's server doubled
// (`opencode-omniroute/opencode-omniroute/<slug>`), and `parseModel()`
// resolves credentials for the nonexistent provider `opencode-omniroute`
// instead of `omniroute`. See #7976.
models[buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId)] = entry;
models[buildComboKey(combo, usedComboKeys, opts.providerId)] = entry;
// Make this combo's resolved entry available to parent combos
// that reference it via combo-ref. Use the friendly name since
@@ -4493,9 +3984,7 @@ type AuthJsonShape = Record<string, AuthJsonApiEntry | { type?: string; [k: stri
/** Disk snapshot envelope. Versioned for forward-compat. */
interface OmniRouteDiskSnapshot {
v: 2;
/** Opaque identity for normalized baseURL + both effective credentials. */
identityFingerprint: string;
v: 1;
rawModels: OmniRouteRawModelEntry[];
rawCombos: OmniRouteRawCombo[];
rawAutoCombos?: OmniRouteRawAutoCombo[];
@@ -4513,68 +4002,24 @@ export function diskSnapshotPath(providerId: string): string {
return path.join(dir, "plugins", `omniroute-${providerId}.json`);
}
/** Best-effort delete of the disk snapshot for a provider (force-sync). */
export async function clearDiskSnapshot(providerId: string): Promise<boolean> {
const file = diskSnapshotPath(providerId);
try {
await unlink(file);
return true;
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "ENOENT") return false;
return false;
}
}
export type OmniRouteDiskSnapshotWriter = (
providerId: string,
entry: Omit<OmniRouteFetchCacheEntry, "expiresAt">,
identityFingerprint: string
entry: Omit<OmniRouteFetchCacheEntry, "expiresAt">
) => Promise<void>;
export type OmniRouteDiskSnapshotReader = (
providerId: string,
identityFingerprint: string
providerId: string
) => Promise<Omit<OmniRouteFetchCacheEntry, "expiresAt"> | undefined>;
/**
* Bind a snapshot to the endpoint and effective credential tuple without
* persisting any raw token. This opaque value is only stored and compared;
* it is never logged or included in generated provider configuration.
*/
function diskSnapshotIdentityFingerprint(
baseURL: string,
apiKey: string,
managementReadToken: string
): string {
let normalizedBaseURL: string;
try {
const parsed = new URL(baseURL);
parsed.hash = "";
parsed.pathname = trimTrailingSlashes(parsed.pathname) || "/";
normalizedBaseURL = parsed.toString();
} catch {
normalizedBaseURL = trimTrailingSlashes(baseURL);
}
return createHash("sha256")
.update(JSON.stringify([normalizedBaseURL, apiKey, managementReadToken]))
.digest("hex");
}
/** Best-effort disk write. Soft-fails on any I/O error (no exception thrown). */
export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (
providerId,
entry,
identityFingerprint
) => {
export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (providerId, entry) => {
try {
const file = diskSnapshotPath(providerId);
// Restrict perms to the owner: the snapshot lives alongside auth.json
// (0o600) and embeds provider topology + masked connection records.
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
const snapshot: OmniRouteDiskSnapshot = {
v: 2,
identityFingerprint,
v: 1,
rawModels: entry.rawModels,
rawCombos: entry.rawCombos,
rawAutoCombos: entry.rawAutoCombos,
@@ -4593,22 +4038,12 @@ export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (
};
/** Best-effort disk read. Returns `undefined` when missing/corrupt/unreadable. */
export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (
providerId,
identityFingerprint
) => {
export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (providerId) => {
try {
const file = diskSnapshotPath(providerId);
const body = await readFile(file, "utf8");
const parsed = JSON.parse(body) as Partial<OmniRouteDiskSnapshot>;
if (
!parsed ||
parsed.v !== 2 ||
typeof parsed.identityFingerprint !== "string" ||
parsed.identityFingerprint !== identityFingerprint
) {
return undefined;
}
if (!parsed || parsed.v !== 1) return undefined;
return {
rawModels: Array.isArray(parsed.rawModels) ? parsed.rawModels : [],
rawCombos: Array.isArray(parsed.rawCombos) ? parsed.rawCombos : [],
@@ -5032,9 +4467,6 @@ export function createOmniRouteConfigHook(
);
return;
}
// Management-plane catalog reads may use a narrower read-only token.
// Backward compatibility: when unset, retain the historical apiKey use.
const managementReadToken = resolved.managementReadToken ?? apiKey;
// baseURL resolution: opts.baseURL wins, then auth.json's stored baseURL.
// No silent localhost default — a misconfigured plugin should surface a
@@ -5051,12 +4483,7 @@ export function createOmniRouteConfigHook(
// Try the shared cache first. On OC ≥1.14.49 the provider hook may have
// populated it moments earlier; on OC ≤1.14.48 only this hook runs but
// the cache still works (single producer + consumer through one Map).
const cacheKey = modelsCacheKey(baseURL, `${apiKey}\0${managementReadToken}`);
const snapshotFingerprint = diskSnapshotIdentityFingerprint(
baseURL,
apiKey,
managementReadToken
);
const cacheKey = modelsCacheKey(baseURL, apiKey);
const t = now();
const cached = cache.get(cacheKey);
@@ -5097,7 +4524,7 @@ export function createOmniRouteConfigHook(
rawCombos = [];
try {
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
rawCombos = await combosFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
@@ -5108,7 +4535,7 @@ export function createOmniRouteConfigHook(
rawAutoCombos = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
rawAutoCombos = await autoCombosFetcher(baseURL, apiKey, 5_000);
} catch {
// Already handled inside the default fetcher
}
@@ -5124,7 +4551,7 @@ export function createOmniRouteConfigHook(
rawEnrichment = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
@@ -5139,7 +4566,7 @@ export function createOmniRouteConfigHook(
rawCompressionCombos = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
@@ -5155,7 +4582,7 @@ export function createOmniRouteConfigHook(
rawConnections = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
rawConnections = await providersFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
@@ -5171,7 +4598,7 @@ export function createOmniRouteConfigHook(
// a healthy refresh; staleness is bounded only by how recently the
// user was online.
if (modelsFetchThrew && wantDiskCache) {
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
const snapshot = await diskSnapshotReader(resolved.providerId);
if (snapshot && snapshot.rawModels.length > 0) {
logger.warn(
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
@@ -5216,18 +4643,14 @@ export function createOmniRouteConfigHook(
// Best-effort; soft-fail keeps us moving when the data dir isn't
// writable (e.g. read-only container).
if (modelsFetchOk && wantDiskCache) {
await diskSnapshotWriter(
resolved.providerId,
{
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
},
snapshotFingerprint
);
await diskSnapshotWriter(resolved.providerId, {
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
});
}
}

View File

@@ -1,130 +0,0 @@
/**
* Auto-discovery + force-sync (OpenCode parity with Pi `/omni sync`).
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
sanitizeAutoSyncIntervalMs,
DEFAULT_AUTO_SYNC_INTERVAL_MS,
MIN_AUTO_SYNC_INTERVAL_MS,
parseOmniRoutePluginOptions,
resolveOmniRoutePluginOptions,
invalidateOmniRouteFetchCache,
forceSyncOmniRouteModels,
type OmniRouteFetchCache,
} from "../src/index.js";
test("sanitizeAutoSyncIntervalMs: unset → default 300000", () => {
assert.equal(sanitizeAutoSyncIntervalMs(undefined), DEFAULT_AUTO_SYNC_INTERVAL_MS);
assert.equal(sanitizeAutoSyncIntervalMs(null), DEFAULT_AUTO_SYNC_INTERVAL_MS);
});
test("sanitizeAutoSyncIntervalMs: 0 disables", () => {
assert.equal(sanitizeAutoSyncIntervalMs(0), 0);
});
test("sanitizeAutoSyncIntervalMs: clamps below min to 60000", () => {
assert.equal(sanitizeAutoSyncIntervalMs(1), MIN_AUTO_SYNC_INTERVAL_MS);
assert.equal(sanitizeAutoSyncIntervalMs(59_999), MIN_AUTO_SYNC_INTERVAL_MS);
});
test("sanitizeAutoSyncIntervalMs: keeps valid values", () => {
assert.equal(sanitizeAutoSyncIntervalMs(60_000), 60_000);
assert.equal(sanitizeAutoSyncIntervalMs(300_000), 300_000);
});
test("parseOmniRoutePluginOptions accepts autoSyncIntervalMs including 0", () => {
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 0 }).autoSyncIntervalMs, 0);
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs, 120_000);
});
test("resolveOmniRoutePluginOptions defaults autoSyncIntervalMs to 300000", () => {
const r = resolveOmniRoutePluginOptions({});
assert.equal(r.autoSyncIntervalMs, DEFAULT_AUTO_SYNC_INTERVAL_MS);
});
test("resolveOmniRoutePluginOptions clamps low positive autoSyncIntervalMs", () => {
const r = resolveOmniRoutePluginOptions({ autoSyncIntervalMs: 5000 });
assert.equal(r.autoSyncIntervalMs, MIN_AUTO_SYNC_INTERVAL_MS);
});
test("invalidateOmniRouteFetchCache clears by baseURL prefix", () => {
const cache: OmniRouteFetchCache = new Map();
cache.set("https://a.example/v1::abc", {
rawModels: [],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
expiresAt: Date.now() + 1000,
});
cache.set("https://b.example/v1::def", {
rawModels: [],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
expiresAt: Date.now() + 1000,
});
const removed = invalidateOmniRouteFetchCache(cache, "https://a.example/v1");
assert.equal(removed, 1);
assert.equal(cache.size, 1);
assert.equal(cache.has("https://b.example/v1::def"), true);
});
test("forceSyncOmniRouteModels: fetches, populates cache, returns count", async () => {
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
autoSyncIntervalMs: 0,
features: {
combos: false,
autoCombos: false,
enrichment: false,
compressionMetadata: false,
usableOnly: false,
diskCache: false,
},
});
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({
omniroute: { type: "api", key: "test-key" },
}),
fetcher: async () => [
{ id: "model-a", object: "model" },
{ id: "model-b", object: "model" },
],
now: () => 1_000_000,
});
assert.equal(result.ok, true);
assert.equal(result.count, 2);
assert.equal(result.provider, "omniroute");
assert.equal(cache.size, 1);
const entry = [...cache.values()][0];
assert.equal(entry.rawModels.length, 2);
assert.equal(entry.expiresAt, 1_000_000 + resolved.modelCacheTtl);
});
test("forceSyncOmniRouteModels: missing auth returns error", async () => {
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
autoSyncIntervalMs: 0,
features: { diskCache: false },
});
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({}),
});
assert.equal(result.ok, false);
assert.match(result.error ?? "", /credentials|baseURL|connect/i);
});

View File

@@ -248,7 +248,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
// Combo surfaces under bare key + LCD'd
// (gemini's reasoning=false → combo reasoning=false).
const combo = entry.models["omniroute/claude-tier"];
const combo = entry.models["opencode-omniroute/claude-tier"];
assert.ok(combo, "combo surfaced under bare key");
assert.equal(combo.name, "Claude Tier");
assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false");
@@ -474,7 +474,7 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
"opencode-omniroute/claude-sonnet-4-6",
"opencode-omniroute/gemini-3-flash",
]);
assert.equal(entry.models["omniroute/claude-tier"], undefined, "no combo entry");
assert.equal(entry.models["opencode-omniroute/claude-tier"], undefined, "no combo entry");
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
"combos-fetch breadcrumb emitted"
@@ -748,7 +748,7 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
"https://or.example/v1",
"sk-test"
);
assert.equal(block.models["omniroute/claude-tier"], undefined);
assert.equal(block.models["opencode-omniroute/claude-tier"], undefined);
assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
});
@@ -858,7 +858,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
"https://or.example/v1",
"sk-test"
);
const combo = block.models["omniroute/mixed-tier"];
const combo = block.models["opencode-omniroute/mixed-tier"];
assert.ok(combo, "combo emitted under slug key");
// claude has text+image, text-only has text → intersection drops image.
assert.deepEqual(combo.modalities?.input, ["text"]);
@@ -970,7 +970,7 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini 3 Flash");
// Combo names still come from /api/combos — enrichment overlay does NOT touch combos.
assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier");
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
assert.equal(enrichmentFetcher.callCount(), 1);
});
@@ -1337,7 +1337,7 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
);
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier");
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
});
test("config: providerTag=false suppresses the suffix", async () => {
@@ -1516,7 +1516,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros
);
// Pre-fix: Parent would advertise 200_000 (only raw-big counted).
// Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck).
const parent = block.models["omniroute/parent"];
const parent = block.models["opencode-omniroute/parent"];
assert.ok(parent, "Parent combo must be in the static catalog");
assert.equal(parent.limit?.context, 8_000);
});

View File

@@ -40,7 +40,7 @@ test("defaultDiskSnapshotWriter writes an owner-only (no group/other) snapshot",
process.env.OPENCODE_DATA_DIR = tmp;
try {
await defaultDiskSnapshotWriter("perm-test", makeEntry(), "test-snapshot-identity");
await defaultDiskSnapshotWriter("perm-test", makeEntry());
const file = diskSnapshotPath("perm-test");
assert.ok(fs.existsSync(file), "snapshot file should be written");

View File

@@ -50,52 +50,6 @@ test("createOmniRouteFetchInterceptor: targets baseURL → Authorization header
}
});
test("createOmniRouteFetchInterceptor: path-prefixed baseURL scopes auth to its normalized inference paths", async () => {
const { calls, restore } = installFetchRecorder();
try {
const prefixedBase = "https://or.example.com/tenant-a/v1";
const f = createOmniRouteFetchInterceptor({
apiKey: KEY,
baseURL: `${prefixedBase}///`,
});
const streamingBody = '{"stream":true}';
await f(`${prefixedBase}/chat/completions?trace=1`, {
method: "POST",
body: streamingBody,
headers: { Accept: "text/event-stream" },
});
await f(`${prefixedBase}/models/?refresh=1`);
await f("https://or.example.com/v1/chat/completions", { method: "POST", body: "{}" });
await f("https://or.example.com/v1/models");
await f("https://or.example.com/tenant-b/v1/chat/completions", {
method: "POST",
body: "{}",
});
await f(`${prefixedBase}/chat/completions/batch`, { method: "POST", body: "{}" });
const headers = calls.map(({ init }) => new Headers(init?.headers));
assert.equal(headers[0]?.get("Authorization"), `Bearer ${KEY}`);
assert.equal(headers[1]?.get("Authorization"), `Bearer ${KEY}`);
for (const index of [2, 3, 4, 5]) {
assert.equal(headers[index]?.get("Authorization"), null);
}
assert.equal(calls[0]?.input, `${prefixedBase}/chat/completions?trace=1`);
assert.equal(calls[0]?.init?.body, streamingBody);
assert.equal(headers[0]?.get("Accept"), "text/event-stream");
const suffixingInterceptor = createOmniRouteFetchInterceptor({
apiKey: KEY,
baseURL: "https://or.example.com/tenant-a/",
});
await suffixingInterceptor(`${prefixedBase}/models`);
const suffixedHeaders = new Headers(calls[6]?.init?.headers);
assert.equal(suffixedHeaders.get("Authorization"), `Bearer ${KEY}`);
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization OVERRIDES caller-supplied Bearer", async () => {
const { calls, restore } = installFetchRecorder();
try {
@@ -305,7 +259,7 @@ test("loader integration: wired interceptor actually injects Bearer when invoked
{} as never
);
const wiredFetch = (result as { fetch: typeof fetch }).fetch;
await wiredFetch(`${BASE}/models`, {});
await wiredFetch(`${BASE}/v1/models`, {});
assert.equal(calls.length, 1);
const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);

View File

@@ -1,271 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
createOmniRouteAuthHook,
createOmniRouteConfigHook,
createOmniRouteProviderHook,
parseOmniRoutePluginOptions,
type OmniRouteCompressionMetaFetcher,
type OmniRouteEnrichmentFetcher,
type OmniRouteProvidersFetcher,
type OmniRouteRawModelEntry,
} from "../src/index.js";
const BASE_URL = "https://or.example.com/v1";
const API_KEY = "sk-inference-only";
const MANAGEMENT_READ_TOKEN = "sk-management-read-only";
const RAW_MODELS: OmniRouteRawModelEntry[] = [
{
id: "openai/gpt-test",
context_length: 16_000,
max_output_tokens: 4_000,
capabilities: {
tool_calling: true,
reasoning: false,
vision: false,
thinking: false,
temperature: true,
},
input_modalities: ["text"],
output_modalities: ["text"],
},
];
function apiAuth(key: string) {
return { type: "api" as const, key };
}
test("options: managementReadToken is accepted and preserved", () => {
const parsed = parseOmniRoutePluginOptions({ managementReadToken: MANAGEMENT_READ_TOKEN });
assert.equal(parsed.managementReadToken, MANAGEMENT_READ_TOKEN);
});
test("provider hook: management GET fetchers use managementReadToken while /v1 uses apiKey", async () => {
const calls: Array<[string, string]> = [];
const enrichmentFetcher: OmniRouteEnrichmentFetcher = async (_baseURL, token) => {
calls.push(["pricing", token]);
return new Map();
};
const compressionMetaFetcher: OmniRouteCompressionMetaFetcher = async (_baseURL, token) => {
calls.push(["context", token]);
return [];
};
const providersFetcher: OmniRouteProvidersFetcher = async (_baseURL, token) => {
calls.push(["providers", token]);
return [];
};
const hook = createOmniRouteProviderHook(
{
baseURL: BASE_URL,
managementReadToken: MANAGEMENT_READ_TOKEN,
features: { compressionMetadata: true, usableOnly: true },
},
{
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
autoCombosFetcher: async (_baseURL, token) => {
calls.push(["auto-combos", token]);
return [];
},
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
}
);
await hook.models!({} as never, { auth: apiAuth(API_KEY) as never });
assert.deepEqual(calls, [
["models", API_KEY],
["combos", MANAGEMENT_READ_TOKEN],
["auto-combos", MANAGEMENT_READ_TOKEN],
["pricing", MANAGEMENT_READ_TOKEN],
["context", MANAGEMENT_READ_TOKEN],
["providers", MANAGEMENT_READ_TOKEN],
]);
});
test("provider hook: absent managementReadToken preserves apiKey fallback", async () => {
const calls: Array<[string, string]> = [];
const hook = createOmniRouteProviderHook(
{ baseURL: BASE_URL, features: { enrichment: false, autoCombos: false } },
{
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
}
);
await hook.models!({} as never, { auth: apiAuth(API_KEY) as never });
assert.deepEqual(calls, [
["models", API_KEY],
["combos", API_KEY],
]);
});
test("config hook: managementReadToken stays out of provider inference and MCP config", async () => {
const calls: Array<[string, string]> = [];
const hook = createOmniRouteConfigHook(
{
baseURL: BASE_URL,
managementReadToken: MANAGEMENT_READ_TOKEN,
features: { enrichment: false, autoCombos: false, diskCache: false, mcpAutoEmit: true },
},
{
readAuthJson: async () => ({
"opencode-omniroute": { type: "api" as const, key: API_KEY },
}),
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
logger: { warn: () => {} },
}
);
const input: { provider?: Record<string, any>; mcp?: Record<string, any> } = {};
await hook(input as never);
assert.deepEqual(calls, [
["models", API_KEY],
["combos", MANAGEMENT_READ_TOKEN],
]);
assert.equal(input.provider?.["opencode-omniroute"]?.options?.apiKey, API_KEY);
assert.equal(
input.mcp?.["opencode-omniroute"]?.headers?.Authorization,
`Bearer ${API_KEY}`,
"mcpAutoEmit remains independent of managementReadToken"
);
});
test("auth fetch: only intended same-origin inference paths receive apiKey", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return new Response("ok");
}) as typeof fetch;
try {
const hook = createOmniRouteAuthHook({
baseURL: `${BASE_URL}/`,
managementReadToken: MANAGEMENT_READ_TOKEN,
});
const loaded = await hook.loader!(async () => apiAuth(API_KEY) as never, {} as never);
const interceptedFetch = (loaded as { fetch: typeof fetch }).fetch;
const streamingBody = '{"stream":true}';
await interceptedFetch(`${BASE_URL}/chat/completions?trace=1`, {
method: "POST",
body: streamingBody,
headers: { Accept: "text/event-stream" },
});
await interceptedFetch(`${BASE_URL}/models/?refresh=1`);
await interceptedFetch("https://or.example.com/api/combos");
await interceptedFetch("https://or.example.com/api/mcp/stream");
await interceptedFetch("https://or.example.com/v1/embeddings");
await interceptedFetch("https://third-party.example/v1/chat/completions", {
method: "POST",
body: "{}",
});
const headers = calls.map(({ init }) => new Headers(init?.headers));
assert.equal(headers[0]?.get("Authorization"), `Bearer ${API_KEY}`);
assert.equal(headers[1]?.get("Authorization"), `Bearer ${API_KEY}`);
for (const index of [2, 3, 4, 5]) {
assert.equal(headers[index]?.get("Authorization"), null);
}
assert.equal(calls[0]?.input, `${BASE_URL}/chat/completions?trace=1`);
assert.equal(calls[0]?.init?.body, streamingBody);
assert.equal(headers[0]?.get("Accept"), "text/event-stream");
assert.equal(
calls.some(({ init }) =>
[...new Headers(init?.headers).values()].some((value) =>
value.includes(MANAGEMENT_READ_TOKEN)
)
),
false,
"managementReadToken must never enter inference fetch headers"
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("disk cache: snapshot written under management token A is rejected under token B", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-token-snapshot-"));
const previousDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = tmp;
try {
const commonDeps = {
readAuthJson: async () => ({
"opencode-omniroute": {
type: "api" as const,
key: API_KEY,
baseURL: BASE_URL,
},
}),
combosFetcher: async () => [],
logger: { warn: () => {} },
};
const features = {
enrichment: false,
autoCombos: false,
diskCache: true,
} as const;
const tokenAHook = createOmniRouteConfigHook(
{ managementReadToken: "token-A", features },
{
...commonDeps,
fetcher: async () => RAW_MODELS,
}
);
await tokenAHook({} as never);
const tokenBHook = createOmniRouteConfigHook(
{ managementReadToken: "token-B", features },
{
...commonDeps,
fetcher: async () => {
throw new Error("offline");
},
}
);
const input: { provider?: Record<string, { models: Record<string, unknown> }> } = {};
await tokenBHook(input as never);
assert.deepEqual(
input.provider?.["opencode-omniroute"]?.models,
{},
"catalog from token A must not hydrate after switching to token B"
);
} finally {
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = previousDataDir;
fs.rmSync(tmp, { recursive: true, force: true });
}
});

View File

@@ -22,11 +22,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildStaticProviderEntry,
createOmniRouteProviderHook,
mapRawModelToModelV2,
resolveOmniRoutePluginOptions,
type OmniRouteRawCombo,
} from "../src/index.js";
/**
@@ -99,43 +97,3 @@ test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID
"the OC-gate prefix must never leak into ModelV2.providerID"
);
});
// #7976: buildStaticProviderEntry (the STATIC provider() config-hook path,
// exercised when the plugin writes `opencode.json` up front rather than
// registering the dynamic `provider.models()` hook) never received the
// #6859 fix. OC dispatches a static-catalog `models` map key verbatim as
// the `model` field of the outbound request — only the top-level
// `provider["<id>"]` segment is stripped for routing — so a bare-slug combo
// key built with the OC-gated `providerId` reaches OmniRoute's server
// doubled and fails credential lookup for the nonexistent provider
// `opencode-omniroute`. Confirmed against the issue's own curl repro
// (`model: "opencode-omniroute/hermes-smart-stack"` → "No active
// credentials for provider: opencode-omniroute").
test("#7976: buildStaticProviderEntry keys bare-slug combo ids with the unprefixed omnirouteProviderId (no double OC-gate prefix)", () => {
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
assert.equal(resolved.providerId, "opencode-omniroute");
assert.equal(resolved.omnirouteProviderId, "omniroute");
const combo = {
id: "combo-abc123",
name: "Hermes Smart Stack",
isHidden: false,
models: [],
} as unknown as OmniRouteRawCombo;
const block = buildStaticProviderEntry(
[],
[combo],
resolved,
"https://or.example/v1",
"sk-test"
);
assert.deepEqual(Object.keys(block.models), ["omniroute/hermes-smart-stack"]);
assert.equal(
block.models["opencode-omniroute/hermes-smart-stack"],
undefined,
"combo key must not carry the OC-gate-prefixed providerId — it doubles up once " +
"OC dispatches it verbatim as the `model` field"
);
});

View File

@@ -3,12 +3,12 @@
## Project
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
with **290 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
with **250 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
with **MCP Server** (104 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
with **MCP Server** (94 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
> **Live counts (v3.8.49)**: providers 290 · MCP tools 104 · MCP scopes 30 · A2A skills 6 ·
> **Live counts (v3.8.47)**: providers 250 · MCP tools 94 · MCP scopes 30 · A2A skills 6 ·
> open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 ·
> DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 ·
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
@@ -267,8 +267,8 @@ Zod schemas, and unit tests aligned when editing.
### Provider Categories
- **Free** (2): Qoder AI, Kiro AI
- **OAuth** (13): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, Kimi Coding, Kilo Code, Cline, Kiro, Qoder, Gemini, Windsurf (v3.8), GitLab Duo (v3.8)
- **Free** (3): Qoder AI, Qwen Code, Kiro AI
- **OAuth** (14): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, Kimi Coding, Kilo Code, Cline, Qwen (⚠️ free tier discontinued 2026-04-15), Kiro, Qoder, Gemini, Windsurf (v3.8), GitLab Duo (v3.8)
- **API Key** (120+): OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Perplexity,
Together, Fireworks, Cerebras, Cohere, NVIDIA, Nebius, SiliconFlow, Hyperbolic,
HuggingFace, OpenRouter, Vertex AI, Cloudflare AI, Scaleway, AI/ML API, Pollinations,
@@ -391,7 +391,7 @@ Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
### MCP Server (`open-sse/mcp-server/`)
**104 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 42-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), pool (6), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (31 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
**94 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 34-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), pool (6), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (30 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota,
route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard,
@@ -570,16 +570,12 @@ This repository is a fork of `diegosouzapw/OmniRoute`. Keep fork-only operationa
changes (for example GHCR image publishing, personal deployment workflows, or local
automation) out of upstream contribution PRs.
When preparing a PR for upstream, always start the work branch from the upstream
**default branch** — the active `release/vX.Y.Z` line (today `release/v3.8.49`).
Never branch from `main`: `main` only receives release squash-merges, so a branch
cut there is weeks behind and produces conflict-heavy PRs
(see `CONTRIBUTING.md` and `docs/ops/BRANCHING_MODEL.md`):
When preparing a PR for upstream, always start the work branch from `upstream/main`,
not from this fork's `main`:
```bash
git fetch upstream
# the default branch is the active release line, e.g. release/v3.8.49
git switch -c <branch-name> upstream/release/vX.Y.Z
git switch -c <branch-name> upstream/main
```
Only cherry-pick or reapply the changes intended for the upstream PR.

View File

@@ -4,724 +4,6 @@
---
## [3.8.49] — TBD
_Living section — regenerated 2026-07-19 from all 306 cycle commits (bump 2c62333b0 → tip). Bullets carry the merged PR and its author; direct pushes listed separately. Finalized at the v3.8.49 release._
### ✨ New Features
- **feat:** generalize ensureThinkingBudget to all providers + preserve server-side tool invocations on antigravity ([#6979](https://github.com/diegosouzapw/OmniRoute/pull/6979)) — thanks @rafaumeu
- **feat(6922):** effort-tier aliases for glm-5.2 & mimo-v2.5 on opencode-go ([#6987](https://github.com/diegosouzapw/OmniRoute/pull/6987)) — thanks @rafaumeu
- **feat(providers):** curated OpenRouter embeddings catalog + specialty merge in live discovery (#6976) ([#6994](https://github.com/diegosouzapw/OmniRoute/pull/6994))
- **feat(quota):** opt-in auto-ping to keep Codex quota windows warm (#6977) ([#6995](https://github.com/diegosouzapw/OmniRoute/pull/6995))
- **feat(providers):** add Agnes AI native provider support ([#7035](https://github.com/diegosouzapw/OmniRoute/pull/7035)) — thanks @HouMinXi
- **feat(sse):** allow disabling `:` comment heartbeats via OMNIROUTE_SSE_COMMENTS=off ([#7036](https://github.com/diegosouzapw/OmniRoute/pull/7036)) — thanks @xier2012
- **feat(perf):** add performance.mark/measure to SSE pipeline + request-size metric ([#7045](https://github.com/diegosouzapw/OmniRoute/pull/7045)) — thanks @oyi77
- **feat(providers):** add Dahl free inference provider ([#7062](https://github.com/diegosouzapw/OmniRoute/pull/7062)) — thanks @growab
- **feat(ci):** boot-smoke the packed npm tarball (check:pack-boot, #7065 class killer) ([#7086](https://github.com/diegosouzapw/OmniRoute/pull/7086))
- **feat(ci):** hotfix fast-lane + tests-only E2E skip (WS3.1) ([#7088](https://github.com/diegosouzapw/OmniRoute/pull/7088))
- **feat(ci):** continuous release-green — on-push quick gate + 3x/day full sweep (WS5.1) ([#7089](https://github.com/diegosouzapw/OmniRoute/pull/7089))
- **feat(ci):** duration-balanced E2E shards via LPT bin-packing (WS4.1) ([#7090](https://github.com/diegosouzapw/OmniRoute/pull/7090))
- **feat(ci):** TypeScript 7 native shadow for typecheck:core (WS4.2, advisory) ([#7091](https://github.com/diegosouzapw/OmniRoute/pull/7091))
- **feat(release):** npm staged publishing + pre-publish boot-smoke (WS1.3) ([#7092](https://github.com/diegosouzapw/OmniRoute/pull/7092))
- **feat(release):** post-publish verifier — clean-container install + boot (WS1.4) ([#7109](https://github.com/diegosouzapw/OmniRoute/pull/7109))
- **feat(ci):** Mergify merge queue + manual-train fallback runbook (WS3.4/WS3.2) ([#7112](https://github.com/diegosouzapw/OmniRoute/pull/7112))
- **feat(ci):** Windows leg for Electron prepare smoke (WS1.5) ([#7113](https://github.com/diegosouzapw/OmniRoute/pull/7113))
- **feat(ci):** Codecov patch coverage (informational) + fix missing lcov reporter (WS5.6) ([#7114](https://github.com/diegosouzapw/OmniRoute/pull/7114))
- **feat(sidecar):** support conditional provider manifest refresh ([#7130](https://github.com/diegosouzapw/OmniRoute/pull/7130)) — thanks @KooshaPari
- **feat(homolog):** real-environment E2E homologation suite (npm run homolog) ([#7133](https://github.com/diegosouzapw/OmniRoute/pull/7133))
- **feat(usage):** add Codex reset credit picker ([#7154](https://github.com/diegosouzapw/OmniRoute/pull/7154)) — thanks @JxnLexn
- **feat(ci):** Trunk Flaky Tests uploads for vitest + Playwright E2E (WS5.2/5.3) ([#7175](https://github.com/diegosouzapw/OmniRoute/pull/7175))
- **feat(ci):** Trunk Flaky Tests upload on the fast-path vitest job (per-PR volume) ([#7205](https://github.com/diegosouzapw/OmniRoute/pull/7205))
- **feat(kiro):** register GPT-5.6 Sol/Terra/Luna model family ([#7209](https://github.com/diegosouzapw/OmniRoute/pull/7209))
- **feat(dashboard):** show Codex plan label in provider and quota views ([#7210](https://github.com/diegosouzapw/OmniRoute/pull/7210))
- **feat(dashboard):** add reorder connections by availability button ([#7211](https://github.com/diegosouzapw/OmniRoute/pull/7211))
- **feat(dashboard):** add 180D and 365D usage/cost analytics periods (#7213) ([#7213](https://github.com/diegosouzapw/OmniRoute/pull/7213))
- **feat(api):** add Vary: Accept-Encoding to token-authenticated /v1* responses (#6737) ([#7217](https://github.com/diegosouzapw/OmniRoute/pull/7217))
- **feat(api):** expose GET /api/usage/model-latency-stats (#6873) ([#7218](https://github.com/diegosouzapw/OmniRoute/pull/7218))
- **feat(dashboard):** add compression-mode selector to Context & Cache combos page (#6760) ([#7219](https://github.com/diegosouzapw/OmniRoute/pull/7219))
- **feat(sse):** route GitHub Copilot Claude models through native /v1/messages ([#7223](https://github.com/diegosouzapw/OmniRoute/pull/7223))
- **feat(mitm):** add Antigravity reasoning-effort overrides ([#7228](https://github.com/diegosouzapw/OmniRoute/pull/7228))
- **feat:** replace free-text model inputs with hidePaid-aware Selects (#6540) ([#7229](https://github.com/diegosouzapw/OmniRoute/pull/7229))
- **feat:** editable ComfyUI base-URL field + per-connection override for image/video/music generation (#6928) ([#7232](https://github.com/diegosouzapw/OmniRoute/pull/7232))
- **feat(sse):** add optional-enum null-omission idiom for codex strict-mode tools (#7023) ([#7233](https://github.com/diegosouzapw/OmniRoute/pull/7233))
- **feat(sse):** preserve tools/tool_choice for tool-bearing requests through fusion combos (#6771) ([#7235](https://github.com/diegosouzapw/OmniRoute/pull/7235))
- **feat(api):** accept x-goog-api-key header for client-facing auth (#7034) ([#7236](https://github.com/diegosouzapw/OmniRoute/pull/7236))
- **feat(sse):** add native xAI Grok Imagine video generation provider ([#7238](https://github.com/diegosouzapw/OmniRoute/pull/7238))
- **feat:** add Type filter and easiest-first sort to Free Provider Rankings (#6915) ([#7240](https://github.com/diegosouzapw/OmniRoute/pull/7240))
- **feat(cli):** add Grok Build CLI tool setup (~/.grok/config.toml) ([#7241](https://github.com/diegosouzapw/OmniRoute/pull/7241))
- **feat(provider):** add Chenzk API OpenAI-compatible gateway ([#7246](https://github.com/diegosouzapw/OmniRoute/pull/7246))
- **feat(providers):** let custom connections opt into prompt-cache capability (#6880) ([#7257](https://github.com/diegosouzapw/OmniRoute/pull/7257))
- **feat(db):** include xp_audit_log in automatic retention/prune (#6801) ([#7260](https://github.com/diegosouzapw/OmniRoute/pull/7260))
- **feat(api):** structured X-Routing-Fallback-Reason header for relay routing (#6872) ([#7262](https://github.com/diegosouzapw/OmniRoute/pull/7262))
- **feat(compression):** support RTK TOML schema v1 filters ([#7281](https://github.com/diegosouzapw/OmniRoute/pull/7281)) — thanks @JxnLexn
- **feat:** add principal-scoped CCR MCP lifecycle ([#7282](https://github.com/diegosouzapw/OmniRoute/pull/7282)) — thanks @JxnLexn
- **feat(morph):** refresh curated models ([#7314](https://github.com/diegosouzapw/OmniRoute/pull/7314)) — thanks @backryun
- **feat(issue-agent):** surface RecordedTriageTimeoutError as 504 ([#7315](https://github.com/diegosouzapw/OmniRoute/pull/7315)) — thanks @KooshaPari
- **feat(incident-response):** structured incident response templates ([#7334](https://github.com/diegosouzapw/OmniRoute/pull/7334)) — thanks @KooshaPari
- **feat(providers):** add xAI OAuth PKCE provider ([#7399](https://github.com/diegosouzapw/OmniRoute/pull/7399)) — thanks @fenix007
- **feat(models):** advertise Claude reasoning-effort variants in /v1/models ([#7497](https://github.com/diegosouzapw/OmniRoute/pull/7497)) — thanks @thepigdestroyer
- **feat(kimi):** sync Code, Web, and Moonshot providers ([#7531](https://github.com/diegosouzapw/OmniRoute/pull/7531)) — thanks @backryun
- **feat(resilience):** guard OmniRoute peer routing loops ([#7555](https://github.com/diegosouzapw/OmniRoute/pull/7555)) — thanks @isiahw1
- **feat:** add Mixedbread AI as embeddings provider (#6660) ([#7595](https://github.com/diegosouzapw/OmniRoute/pull/7595))
- **feat(providers):** add Rev AI speech-to-text provider (#6655) ([#7596](https://github.com/diegosouzapw/OmniRoute/pull/7596))
- **feat:** add Freepik (Magnific Mystic) image generation provider (#6654) ([#7597](https://github.com/diegosouzapw/OmniRoute/pull/7597))
- **feat(sse):** add DeepInfra as a video-generation provider (#6653) ([#7598](https://github.com/diegosouzapw/OmniRoute/pull/7598))
- **feat(providers):** add Felo chat-aggregator provider (#6666) ([#7599](https://github.com/diegosouzapw/OmniRoute/pull/7599))
- **feat(sse):** add Notion AI Web (Unofficial/Experimental) provider (#6758) ([#7600](https://github.com/diegosouzapw/OmniRoute/pull/7600))
- **feat:** add FreeTheAi as OpenAI-compatible gateway provider (#6670) ([#7602](https://github.com/diegosouzapw/OmniRoute/pull/7602))
- **feat:** add Gladia as an async speech-to-text provider (#6657) ([#7603](https://github.com/diegosouzapw/OmniRoute/pull/7603))
- **feat:** add EdgeTTS audio-tts provider (#6668) ([#7605](https://github.com/diegosouzapw/OmniRoute/pull/7605))
- **feat(video):** add Novita AI as video-generation provider (#6658) ([#7606](https://github.com/diegosouzapw/OmniRoute/pull/7606))
- **feat:** add Segmind image+video provider (#6656) ([#7608](https://github.com/diegosouzapw/OmniRoute/pull/7608))
- **feat:** add Microsoft Designer as image provider (#6672) ([#7609](https://github.com/diegosouzapw/OmniRoute/pull/7609))
- **feat:** per-model default reasoning_effort + no-think none on OpenAI path (#6879) ([#7631](https://github.com/diegosouzapw/OmniRoute/pull/7631))
- **feat(sse):** per-model upstream header-response timeout override (#6354) ([#7632](https://github.com/diegosouzapw/OmniRoute/pull/7632))
- **feat(dashboard):** in-product guidance for prompt compression engines (#7530) ([#7634](https://github.com/diegosouzapw/OmniRoute/pull/7634))
- **feat(usage):** add TTFT/E2E-latency/tokens-per-second to model latency stats (#6875) ([#7635](https://github.com/diegosouzapw/OmniRoute/pull/7635))
- **feat:** import providers from CSV/JSON file (#6836) ([#7636](https://github.com/diegosouzapw/OmniRoute/pull/7636))
- **feat:** confirm before removing a single connection (#7361) ([#7640](https://github.com/diegosouzapw/OmniRoute/pull/7640))
- **feat(sse):** honor excluded models in no-auth auto-combo candidate pool (#7622) ([#7646](https://github.com/diegosouzapw/OmniRoute/pull/7646))
- **feat(providers):** add g4f.space no-key gateway (groq/gemini/pollinations/ollama/nvidia) (#6650) ([#7647](https://github.com/diegosouzapw/OmniRoute/pull/7647))
- **feat:** rate-limit queue admission control (maxQueueDepth + 15s default) (#6593) ([#7649](https://github.com/diegosouzapw/OmniRoute/pull/7649))
- **feat(sse):** generalize session affinity TTL to all providers (#7274) ([#7650](https://github.com/diegosouzapw/OmniRoute/pull/7650))
- **feat:** OpenRouter quota tracking (key/credits + free-window counter) (#6842) ([#7651](https://github.com/diegosouzapw/OmniRoute/pull/7651))
- **feat(sse):** quota tracking for AgentRouter, v0 (Vercel), FreeModel (#6850, #6845, #7075) ([#7653](https://github.com/diegosouzapw/OmniRoute/pull/7653))
- **feat(providers):** Speechmatics STT, gTTS, VibeProxy preset (#6659, #6667, #6874) ([#7655](https://github.com/diegosouzapw/OmniRoute/pull/7655))
- **feat(api):** route Google AI Studio Imagen through /v1/images/generations ([#7656](https://github.com/diegosouzapw/OmniRoute/pull/7656)) — thanks @danscMax
- **feat(auth):** OIDC as optional dashboard admin login gate (password fallback preserved) ([#6973](https://github.com/diegosouzapw/OmniRoute/pull/6973)) — thanks @mikolaj92
- **feat(api):** add pagination params to 8 DB modules + recharts code-split ([#7046](https://github.com/diegosouzapw/OmniRoute/pull/7046)) — thanks @oyi77
- **feat(proxy):** operator-level proxy subscriptions (Karing-style) — hardened, ready for review ([#7299](https://github.com/diegosouzapw/OmniRoute/pull/7299)) — thanks @xier2012
- **feat(grok-cli):** align with official Grok Build client ([#7358](https://github.com/diegosouzapw/OmniRoute/pull/7358)) — thanks @backryun
- **feat(providers):** Complete GHE Copilot OAuth provider implementation ([#7546](https://github.com/diegosouzapw/OmniRoute/pull/7546)) — thanks @hppsc1215
- **feat(guardrails):** add CredentialMaskerGuardrail for API key/secret redaction ([#7683](https://github.com/diegosouzapw/OmniRoute/pull/7683)) — thanks @Securiteru
- **feat(perplexity):** refresh provider integrations ([#7687](https://github.com/diegosouzapw/OmniRoute/pull/7687)) — thanks @backryun
- **feat(providers):** notion-web live model discovery via getAvailableModels ([#7696](https://github.com/diegosouzapw/OmniRoute/pull/7696)) — thanks @artickc
- **feat(providers):** add proactive cf_clearance/User-Agent hint to grok-web connection dialog (#7567) ([#7713](https://github.com/diegosouzapw/OmniRoute/pull/7713))
- **feat:** add live gRPC-web quota fetcher for grok-cli (#6844) ([#7714](https://github.com/diegosouzapw/OmniRoute/pull/7714))
- **feat(api):** add opt-in auto-sync scheduler for free-proxy sources (#7079) ([#7716](https://github.com/diegosouzapw/OmniRoute/pull/7716))
- **feat(dashboard):** show proxy name in badge, sort saved-proxy picker, default to Saved tab (#7643) ([#7720](https://github.com/diegosouzapw/OmniRoute/pull/7720))
- **feat(cli):** add auth export command for decrypted provider credentials (#6683) ([#7724](https://github.com/diegosouzapw/OmniRoute/pull/7724))
- **feat(oauth):** accept full ChatGPT session JSON for Codex manual import (#6636) ([#7725](https://github.com/diegosouzapw/OmniRoute/pull/7725))
- **feat(sse):** add nvidia NIM local RPM budget + concurrency cap (#6846) ([#7726](https://github.com/diegosouzapw/OmniRoute/pull/7726))
- **feat(gemini-web):** emulate OpenAI tool calling via the webTools prompt shim (#7286) ([#7727](https://github.com/diegosouzapw/OmniRoute/pull/7727))
- **feat(services):** introduce pluggable service-provider contract, migrate 9router (#7333) ([#7730](https://github.com/diegosouzapw/OmniRoute/pull/7730))
- **feat(mitm):** root-CA + per-host leaf certs for AgentBridge static server (#6684) ([#7731](https://github.com/diegosouzapw/OmniRoute/pull/7731))
- **feat(providers):** add hailuo-web (MiniMax web) chat provider (#6673) ([#7734](https://github.com/diegosouzapw/OmniRoute/pull/7734))
- **feat:** browser login for Grok Build provider (#7013) ([#7735](https://github.com/diegosouzapw/OmniRoute/pull/7735))
- **feat(routing):** wire interceptFetch tool interception into the chat pipeline (#7339) ([#7736](https://github.com/diegosouzapw/OmniRoute/pull/7736))
- **feat(sse):** add X-OmniRoute-Decision routing trace header (#6022) ([#7765](https://github.com/diegosouzapw/OmniRoute/pull/7765))
- **feat(providers):** zai-web live model discovery with local-catalog fallback (#7678) ([#7766](https://github.com/diegosouzapw/OmniRoute/pull/7766))
- **feat(api):** sync upstream reasoning.supported_efforts into synced-model catalog (#7694) ([#7767](https://github.com/diegosouzapw/OmniRoute/pull/7767))
- **feat(dashboard):** pin Kimi providers first in category + official supporter card accent ([#7775](https://github.com/diegosouzapw/OmniRoute/pull/7775))
- **feat(chaos+ponytail):** parallel chaos-mode dispatch + ponytail output … ([#7781](https://github.com/diegosouzapw/OmniRoute/pull/7781)) — thanks @Moseyuh333
- **feat(perf):** IC2 — cache provider connections by ID + lazy-decrypt credentials ([#7787](https://github.com/diegosouzapw/OmniRoute/pull/7787)) — thanks @oyi77
- **feat(quality):** gate the free-tier headline so it can never silently drift again ([#7798](https://github.com/diegosouzapw/OmniRoute/pull/7798))
- **feat(providers):** expose an explicit tier override for any provider connection (#7818) ([#7838](https://github.com/diegosouzapw/OmniRoute/pull/7838))
- **feat(routing):** read-only auto/* candidate transparency + per-API-key exclusions (#7819) ([#7839](https://github.com/diegosouzapw/OmniRoute/pull/7839))
- **feat(catalog):** map unmapped free tiers, add navy + aihorde, surface keyless providers ([#7840](https://github.com/diegosouzapw/OmniRoute/pull/7840))
- **feat(providers):** add OpenRouter speech-to-text (audio transcription) provider ([#7861](https://github.com/diegosouzapw/OmniRoute/pull/7861)) — thanks @Tasogarre
- **feat(qwen):** add Qwen3.8 Max Preview catalogs [Part 2/3] ([#7874](https://github.com/diegosouzapw/OmniRoute/pull/7874)) — thanks @backryun
- **feat:** support Bun bundled SQLite runtime ([#7878](https://github.com/diegosouzapw/OmniRoute/pull/7878)) — thanks @Arul-
- **feat(providers):** add 5 free-tier providers (ainative, aion, sealion, routeway, nara) ([#7887](https://github.com/diegosouzapw/OmniRoute/pull/7887))
- **feat(vnc-session):** persistent noVNC browser login for web-cookie providers ([#7892](https://github.com/diegosouzapw/OmniRoute/pull/7892)) — thanks @Capslockb
- **feat(sse):** add PromptQL playground provider (unofficial) ([#7911](https://github.com/diegosouzapw/OmniRoute/pull/7911)) — thanks @artickc
- **feat(cline):** align ClinePass catalog and request protocol ([#7914](https://github.com/diegosouzapw/OmniRoute/pull/7914)) — thanks @backryun
- **feat:** narrow mcp:connect scope + per-key HTTP tool-scope binding (#7895) ([#7967](https://github.com/diegosouzapw/OmniRoute/pull/7967))
- **feat:** provider tab account search + mirrored top pagination (#7937) ([#7968](https://github.com/diegosouzapw/OmniRoute/pull/7968))
- **feat:** canonical numeric helpers + tier-1 (analytics) migration (#7879) ([#7969](https://github.com/diegosouzapw/OmniRoute/pull/7969))
- **feat(sse):** add HyperAgent (hyperagent.com) unofficial web provider ([#7994](https://github.com/diegosouzapw/OmniRoute/pull/7994)) — thanks @artickc
- **feat:** copilot-m365-web tone-selected model variants (#7872) ([#7997](https://github.com/diegosouzapw/OmniRoute/pull/7997))
- **feat(media):** Adobe Firefly image + video generation provider ([#8006](https://github.com/diegosouzapw/OmniRoute/pull/8006)) — thanks @artickc
- **feat(routing):** add prompt-cache affinity ([#8008](https://github.com/diegosouzapw/OmniRoute/pull/8008)) — thanks @JxnLexn
- **feat(compression):** select model-aware tokenizers ([#8009](https://github.com/diegosouzapw/OmniRoute/pull/8009)) — thanks @JxnLexn
- **feat(compression):** add Responses tool-output engine ([#8010](https://github.com/diegosouzapw/OmniRoute/pull/8010)) — thanks @JxnLexn
- **feat(dashboard):** Kimi sponsor banner, Kimi Coding preset, official logomarks and partner links ([#8039](https://github.com/diegosouzapw/OmniRoute/pull/8039))
- **feat(dashboard):** make Codex quota card windows reflect reality ([#8054](https://github.com/diegosouzapw/OmniRoute/pull/8054)) — thanks @insoln
- **feat(compression):** teach the model the CCR retrieve protocol on first marker (#8033) ([#8063](https://github.com/diegosouzapw/OmniRoute/pull/8063))
- **feat(compression):** per-model/endpoint compression exclusion filter (#8034) ([#8064](https://github.com/diegosouzapw/OmniRoute/pull/8064))
- **feat(providers):** add CLOVA Studio, InternLM and Ant Ling API-key providers ([#8077](https://github.com/diegosouzapw/OmniRoute/pull/8077)) — thanks @alvaretto
- **feat(codex):** support reference image edits ([#8122](https://github.com/diegosouzapw/OmniRoute/pull/8122)) — thanks @xiaoyaner0201
- **feat(providers):** add weekly quota tracking for grok-web ([#8127](https://github.com/diegosouzapw/OmniRoute/pull/8127)) — thanks @apoapostolov
- **feat(providers):** add Sarvam AI, Writer Palmyra and PLaMo API-key providers ([#8161](https://github.com/diegosouzapw/OmniRoute/pull/8161)) — thanks @alvaretto
- **feat:** native Fish Audio TTS provider on /v1/audio/speech (#8099) ([#8164](https://github.com/diegosouzapw/OmniRoute/pull/8164))
- **feat:** zh-CN terminology glossary + consistency gate + normalization pass (#8038) ([#8166](https://github.com/diegosouzapw/OmniRoute/pull/8166))
- **feat(providers):** add Typhoon (Thailand) and Inception Mercury diffusion LLM ([#8170](https://github.com/diegosouzapw/OmniRoute/pull/8170)) — thanks @alvaretto
- **feat(sse):** restrict auto-combo no-auth pool to allowlist (opencode, felo) + docs ([#8183](https://github.com/diegosouzapw/OmniRoute/pull/8183))
- **feat(sre):** add tcp-close-analyzer.py for debugging client-vs-server TCP close order ([#8208](https://github.com/diegosouzapw/OmniRoute/pull/8208)) — thanks @hartmark
- **feat(settings):** configurable model catalog cache TTL ([#8219](https://github.com/diegosouzapw/OmniRoute/pull/8219)) — thanks @oyi77
- **feat(github-models):** refresh catalog and compatibility ([#8225](https://github.com/diegosouzapw/OmniRoute/pull/8225)) — thanks @backryun
- **feat(github):** refresh Copilot model catalog ([#8226](https://github.com/diegosouzapw/OmniRoute/pull/8226)) — thanks @backryun
- **feat:** classify grok-web Cloudflare anti-bot blocks + gated browser-backed cf_clearance path (#8019) ([#8241](https://github.com/diegosouzapw/OmniRoute/pull/8241))
- **feat(providers):** weekly quota for xAI OAuth (Grok) (`xai-oauth` / `xao`) via shared cli-chat-proxy billing (`creditUsagePercent`), with fail-open self-tracked fallback ([#8471](https://github.com/diegosouzapw/OmniRoute/pull/8471)) — thanks @allanvb
### ⚡ Performance
- **perf(db):** project columns + composite index in getProviderConnections ([#6918](https://github.com/diegosouzapw/OmniRoute/pull/6918)) — thanks @oyi77
- **perf(db):** add jitter to stagger due-on-restart connections ([#6919](https://github.com/diegosouzapw/OmniRoute/pull/6919)) — thanks @oyi77
- **perf(startup):** warm model catalog cache at module init ([#6920](https://github.com/diegosouzapw/OmniRoute/pull/6920)) — thanks @oyi77
- **perf(db):** add temp_store=MEMORY pragma to SQLite init ([#6921](https://github.com/diegosouzapw/OmniRoute/pull/6921)) — thanks @oyi77
- **perf(db):** cap modelLockouts eviction at 1000 entries ([#6923](https://github.com/diegosouzapw/OmniRoute/pull/6923)) — thanks @oyi77
- **perf:** wrap ComboCard, HeroSection in React.memo ([#7070](https://github.com/diegosouzapw/OmniRoute/pull/7070)) — thanks @oyi77
- **perf:** Date.now hoist, hasActiveDeltaValue hoist, buffer.split guard in SSE stream ([#7066](https://github.com/diegosouzapw/OmniRoute/pull/7066)) — thanks @oyi77
- **perf(memory):** mitigate event-loop starvation under 3000+ provider connections ([#7719](https://github.com/diegosouzapw/OmniRoute/pull/7719)) — thanks @oyi77
- **perf:** reduce long-context request copies ([#7862](https://github.com/diegosouzapw/OmniRoute/pull/7862)) — thanks @RaviTharuma
- **perf:** lazy provider init, P2C quota cache, structuredClone elimination, getSettings→getCachedSettings (batch 2) ([#7893](https://github.com/diegosouzapw/OmniRoute/pull/7893)) — thanks @oyi77
### 🐛 Bug Fixes
- **fix:** add re-entrancy guard to token health check sweep ([#6917](https://github.com/diegosouzapw/OmniRoute/pull/6917)) — thanks @oyi77
- **fix(grok):** strip reasoningEffort for grok cli models ([#6938](https://github.com/diegosouzapw/OmniRoute/pull/6938)) — thanks @CitrusIce
- **fix(6954,6953):** preserve system role + strip empty-signature thinking blocks ([#6982](https://github.com/diegosouzapw/OmniRoute/pull/6982)) — thanks @rafaumeu
- **fix(6980):** classify Cloudflare AI neuron exhaustion as quota_exhausted ([#6983](https://github.com/diegosouzapw/OmniRoute/pull/6983)) — thanks @rafaumeu
- **fix(dashboard):** hide disabled provider connections from combo builder ([#6984](https://github.com/diegosouzapw/OmniRoute/pull/6984))
- **fix(providers):** cap grok-cli tools at 200 for cli-chat-proxy ([#6986](https://github.com/diegosouzapw/OmniRoute/pull/6986))
- **fix(6848):** auto-cleanup for telemetry tables causing OOM ([#6988](https://github.com/diegosouzapw/OmniRoute/pull/6988)) — thanks @rafaumeu
- **fix(models):** preserve direct-model combo metadata ([#6993](https://github.com/diegosouzapw/OmniRoute/pull/6993)) — thanks @JxnLexn
- **fix:** DDG circuit breaker (#6999) + null content validation (#7000) ([#7001](https://github.com/diegosouzapw/OmniRoute/pull/7001)) — thanks @rafaumeu
- **fix(models):** preserve chat-capable image model rows ([#7004](https://github.com/diegosouzapw/OmniRoute/pull/7004)) — thanks @xz-dev
- **fix(codex):** preserve GPT-5.6 reasoning contract ([#7012](https://github.com/diegosouzapw/OmniRoute/pull/7012)) — thanks @xz-dev
- **fix(base-red):** align least-used combo tests with executionKey usage keying ([#7015](https://github.com/diegosouzapw/OmniRoute/pull/7015))
- **fix:** infer bare models from active synced catalogs ([#7028](https://github.com/diegosouzapw/OmniRoute/pull/7028)) — thanks @guanbear
- **fix(auggie):** update model registry to match v0.32.0 CLI model IDs ([#7032](https://github.com/diegosouzapw/OmniRoute/pull/7032)) — thanks @oyi77
- **fix(sse):** register ollama-cloud in USAGE_FETCHER_PROVIDERS (#7026) ([#7041](https://github.com/diegosouzapw/OmniRoute/pull/7041)) — thanks @alltomatos
- **fix(quality):** read cognitiveComplexity= machine line in validate-release-green (#7009) ([#7042](https://github.com/diegosouzapw/OmniRoute/pull/7042)) — thanks @alltomatos
- **fix(providers):** sanitize Claude native output_config.effort (#7044) ([#7050](https://github.com/diegosouzapw/OmniRoute/pull/7050)) — thanks @xier2012
- **fix(combo):** treat maxInputTokens as an input-only cap in the context filter (#7039) ([#7052](https://github.com/diegosouzapw/OmniRoute/pull/7052)) — thanks @xier2012
- **fix(antigravity):** collect native part.functionCall into tool calls (#7037) ([#7053](https://github.com/diegosouzapw/OmniRoute/pull/7053)) — thanks @xier2012
- **fix(responses):** map mid-conversation system turns to developer role (#6954) ([#7056](https://github.com/diegosouzapw/OmniRoute/pull/7056)) — thanks @xier2012
- **fix(combo):** least-used sorts by per-account executionKey (#7015) ([#7059](https://github.com/diegosouzapw/OmniRoute/pull/7059)) — thanks @xier2012
- **fix(providers):** AgentRouter model import applies Claude Code wire image to /v1/models (#7016) ([#7060](https://github.com/diegosouzapw/OmniRoute/pull/7060)) — thanks @xier2012
- **fix(translator):** preserve thinking.budget_tokens: 0 in Claude->Gemini (#6813) ([#7061](https://github.com/diegosouzapw/OmniRoute/pull/7061)) — thanks @xier2012
- **fix(cloudflare-relay):** avoid invalid regex syntax in generated worker ([#7063](https://github.com/diegosouzapw/OmniRoute/pull/7063)) — thanks @SeaXen
- **fix(dashboard):** strip browser-extension attrs before hydration ([#7073](https://github.com/diegosouzapw/OmniRoute/pull/7073)) — thanks @MrFadiAi
- **fix(relay):** bound Bifrost stream lifetime ([#7093](https://github.com/diegosouzapw/OmniRoute/pull/7093)) — thanks @KooshaPari
- **fix(sse):** recognize xiaomi-tokenplan mimo as a thinking-mode model ([#7098](https://github.com/diegosouzapw/OmniRoute/pull/7098))
- **fix(codex):** strip regex lookaround from tool schema patterns ([#7100](https://github.com/diegosouzapw/OmniRoute/pull/7100))
- **fix(openai):** strip reasoning_effort when GPT-5.x models carry function tools ([#7101](https://github.com/diegosouzapw/OmniRoute/pull/7101))
- **fix(compression):** Headroom SmartCrusher skips developer-role messages (port from 9router#2132) ([#7102](https://github.com/diegosouzapw/OmniRoute/pull/7102))
- **fix(providers):** surface a warning on 404 model_not_found in OpenAI-compatible Check (port from 9router#2032) ([#7103](https://github.com/diegosouzapw/OmniRoute/pull/7103))
- **fix(executors):** forward X-Session-ID/X-Title agent metadata headers ([#7104](https://github.com/diegosouzapw/OmniRoute/pull/7104))
- **fix(cli):** verify better-sqlite3 native binary is actually loadable ([#7105](https://github.com/diegosouzapw/OmniRoute/pull/7105))
- **fix(sse):** sanitize non-ok Antigravity streaming error body (port from 9router#2461) ([#7106](https://github.com/diegosouzapw/OmniRoute/pull/7106))
- **fix(providers):** add MiniMax image-generation provider ([#7108](https://github.com/diegosouzapw/OmniRoute/pull/7108))
- **fix(sse):** handle space-separated arg name/value in Composer tool calls (port from 9router#1811) ([#7116](https://github.com/diegosouzapw/OmniRoute/pull/7116))
- **fix(cli):** remove MITM DNS spoof entries before killing server process ([#7117](https://github.com/diegosouzapw/OmniRoute/pull/7117))
- **fix(dashboard):** include never-tested connections in combo builder active-provider list (port from 9router#2057) ([#7118](https://github.com/diegosouzapw/OmniRoute/pull/7118))
- **fix(api):** check Vercel SSO-protection PATCH response on relay deploy ([#7119](https://github.com/diegosouzapw/OmniRoute/pull/7119))
- **fix(combos):** reject oversized fusion panels before fan-out (port from 9router#1905) ([#7120](https://github.com/diegosouzapw/OmniRoute/pull/7120))
- **fix(combo):** detect empty content_block in streaming SSE peek ([#7121](https://github.com/diegosouzapw/OmniRoute/pull/7121))
- **fix(oauth):** resolve Kiro AWS SSO cache client credentials by clientId match (port from 9router#1253) ([#7122](https://github.com/diegosouzapw/OmniRoute/pull/7122))
- **fix(tests):** vitest UI suite back to green (69 fails triaged — WS6.1) ([#7127](https://github.com/diegosouzapw/OmniRoute/pull/7127))
- **fix(auto):** use p95 fallback in speed factors ([#7128](https://github.com/diegosouzapw/OmniRoute/pull/7128)) — thanks @KooshaPari
- **fix(models):** update Anthropic model contextLength to 1M ([#7129](https://github.com/diegosouzapw/OmniRoute/pull/7129)) — thanks @HouMinXi
- **fix(ci):** raise dast-smoke timeout 12->25min (build alone eats up to 11min) ([#7139](https://github.com/diegosouzapw/OmniRoute/pull/7139))
- **fix(compression):** lazy-load typescript in RTK codeStripper so prod-lean deploys don't break (#7096) ([#7164](https://github.com/diegosouzapw/OmniRoute/pull/7164)) — thanks @alltomatos
- **fix(providers):** accept m365.cloud.microsoft for copilot-m365-web token (#7078) ([#7166](https://github.com/diegosouzapw/OmniRoute/pull/7166)) — thanks @xier2012
- **fix(executors):** disable parallel tools for Codex Responses Lite ([#7171](https://github.com/diegosouzapw/OmniRoute/pull/7171)) — thanks @fenix007
- **fix(tests+providers):** env-dependent tests exposed by GH-hosted runners (#6634 selfref shallow checkout + yuanbao live-network 401) ([#7174](https://github.com/diegosouzapw/OmniRoute/pull/7174))
- **fix(combo):** reject known context overflow without exhausting providers ([#7177](https://github.com/diegosouzapw/OmniRoute/pull/7177)) — thanks @JxnLexn
- **fix:** add static.cloudflareinsights.com to CSP script-src ([#7178](https://github.com/diegosouzapw/OmniRoute/pull/7178)) — thanks @oyi77
- **fix:** extend turbopack ignoreIssue suppression to compression module (#7051) ([#7180](https://github.com/diegosouzapw/OmniRoute/pull/7180))
- **fix:** recognize Ollama Cloud session usage-limit 429 as quota-exhausted (#7071) ([#7181](https://github.com/diegosouzapw/OmniRoute/pull/7181))
- **fix:** preserve relayAuth for pool-referenced relay proxies (#5716) ([#7182](https://github.com/diegosouzapw/OmniRoute/pull/7182))
- **fix:** wire adaptive context-budget dial into settings schema and DB (#7005) ([#7183](https://github.com/diegosouzapw/OmniRoute/pull/7183))
- **fix(providers):** DuckDuckGo VQD 429 misclassified as 503 (#6996) ([#7185](https://github.com/diegosouzapw/OmniRoute/pull/7185))
- **fix(db):** cap OOM probe-failure cycle in getDbInstance() (#6835) ([#7186](https://github.com/diegosouzapw/OmniRoute/pull/7186))
- **fix:** stop opencode-go quota lookup defaulting to Z.AI endpoint (#7022) ([#7187](https://github.com/diegosouzapw/OmniRoute/pull/7187))
- **fix(providers):** refresh OpenCode (oc) free-tier model catalog (#6998) ([#7188](https://github.com/diegosouzapw/OmniRoute/pull/7188))
- **fix:** include proxyId when testing a saved registry proxy (#7080) ([#7189](https://github.com/diegosouzapw/OmniRoute/pull/7189))
- **fix:** sanitize non-Latin1 chars in combo diagnostic headers (#6612) ([#7190](https://github.com/diegosouzapw/OmniRoute/pull/7190))
- **fix:** raise main server keepAliveTimeout/headersTimeout above Node's 5s default (#7003) ([#7191](https://github.com/diegosouzapw/OmniRoute/pull/7191))
- **fix:** route zai-web (and other registry-entry web-cookie providers) connection-test cookie probe through the configured proxy (#7058) ([#7192](https://github.com/diegosouzapw/OmniRoute/pull/7192))
- **fix(providers):** reject chat requests for cloud-agent-only jules provider (#6699) ([#7193](https://github.com/diegosouzapw/OmniRoute/pull/7193))
- **fix:** restore mobile grid-cols-1 fallback on quota page card grid (#7072) ([#7194](https://github.com/diegosouzapw/OmniRoute/pull/7194))
- **fix:** wire modelAliases fetch into HermesAgentToolCard (#7151) ([#7195](https://github.com/diegosouzapw/OmniRoute/pull/7195))
- **fix:** surface real claude-web error body for non-SSE 400s (#7134) ([#7196](https://github.com/diegosouzapw/OmniRoute/pull/7196))
- **fix(dashboard):** agent bridge dns toggle uses POST, not PUT (#7157) ([#7197](https://github.com/diegosouzapw/OmniRoute/pull/7197))
- **fix:** stop duplicating text in Gemini Web streamed responses (#7163) ([#7198](https://github.com/diegosouzapw/OmniRoute/pull/7198))
- **fix:** filter hidden custom models out of legacy combo model picker (#7156) ([#7199](https://github.com/diegosouzapw/OmniRoute/pull/7199))
- **fix(dashboard):** implement missing handleToggleSource on Free Pool tab (#7161) ([#7200](https://github.com/diegosouzapw/OmniRoute/pull/7200))
- **fix:** honor combo-level proxy assignments from the registry (#7149) ([#7201](https://github.com/diegosouzapw/OmniRoute/pull/7201))
- **fix(ci):** run quality gates on Mergify merge-queue draft PRs (anchor check never ran, queue always dequeued) ([#7202](https://github.com/diegosouzapw/OmniRoute/pull/7202))
- **fix:** add dashboard-scoped typecheck gate covering src/app/(dashboard) TSX (#7033) ([#7203](https://github.com/diegosouzapw/OmniRoute/pull/7203))
- **fix(guardrails/chat):** stop Vision Bridge hijacking credentialed models to opencode-zen ([#7204](https://github.com/diegosouzapw/OmniRoute/pull/7204)) — thanks @artickc
- **fix(translator):** preserve Gemini thought parts as reasoning_content on the OpenAI bridge ([#7206](https://github.com/diegosouzapw/OmniRoute/pull/7206))
- **fix(translator):** register openai response projection for gemini clients ([#7207](https://github.com/diegosouzapw/OmniRoute/pull/7207))
- **fix(cli):** fast-path --version to skip full CLI bootstrap ([#7208](https://github.com/diegosouzapw/OmniRoute/pull/7208))
- **fix:** honor PROVIDER_LIMITS_SYNC_SPACING_MS for local/API-key connections (#6916) ([#7214](https://github.com/diegosouzapw/OmniRoute/pull/7214))
- **fix(api):** bulk-add API keys no longer overwrite existing connections ([#7234](https://github.com/diegosouzapw/OmniRoute/pull/7234))
- **fix(sse):** route the public OpenAI GPT-5.6 family through the Responses API ([#7242](https://github.com/diegosouzapw/OmniRoute/pull/7242))
- **fix(providers):** honor configured proxy on Grok Build egress ([#7244](https://github.com/diegosouzapw/OmniRoute/pull/7244))
- **fix(nvidia):** expand NIM chat model catalog ([#7247](https://github.com/diegosouzapw/OmniRoute/pull/7247))
- **fix(sse):** reconstruct Claude-format content in synthetic bypass responses ([#7248](https://github.com/diegosouzapw/OmniRoute/pull/7248))
- **fix(build):** isolate Windows HOME/AppData during next build ([#7249](https://github.com/diegosouzapw/OmniRoute/pull/7249))
- **fix(cli):** omniroute dashboard respects PORT env when --port is omitted (#7049) ([#7252](https://github.com/diegosouzapw/OmniRoute/pull/7252))
- **fix(sse):** project non-streaming JSON back to the Gemini/Antigravity envelope ([#7255](https://github.com/diegosouzapw/OmniRoute/pull/7255))
- **fix(combo):** fall back on Responses SSE failures ([#7256](https://github.com/diegosouzapw/OmniRoute/pull/7256)) — thanks @rushsinging
- **fix(routing):** resolve nested combo-ref panel members in fusion strategy (#6764) ([#7259](https://github.com/diegosouzapw/OmniRoute/pull/7259))
- **fix(usage):** reset logs and show provider names in analytics ([#7264](https://github.com/diegosouzapw/OmniRoute/pull/7264)) — thanks @SeaXen
- **fix(sse):** silence noisy proxy-failure log on caller-initiated abort ([#7266](https://github.com/diegosouzapw/OmniRoute/pull/7266))
- **fix(codex):** normalize nested Responses output content ([#7269](https://github.com/diegosouzapw/OmniRoute/pull/7269)) — thanks @JxnLexn
- **fix(combo):** derive session stickiness key from Responses API .input, not just .messages (#7270) ([#7277](https://github.com/diegosouzapw/OmniRoute/pull/7277)) — thanks @alltomatos
- **fix(antigravity):** wrap Pro fallback chain in try/catch for timeout resilience ([#7290](https://github.com/diegosouzapw/OmniRoute/pull/7290)) — thanks @HouMinXi
- **fix(logs):** show saved provider names in request/provider log views ([#7294](https://github.com/diegosouzapw/OmniRoute/pull/7294)) — thanks @SeaXen
- **fix(stream):** reconcile encrypted Codex reasoning visibility without mutating upstream item ([#7304](https://github.com/diegosouzapw/OmniRoute/pull/7304))
- **fix(build):** packed tarball boot crash — server-ws timeout import escaped the package (#7065 class) ([#7308](https://github.com/diegosouzapw/OmniRoute/pull/7308))
- **fix(skills):** register cli-skill-collector in the agent-skills catalog (Integration 2/2 base-red) ([#7310](https://github.com/diegosouzapw/OmniRoute/pull/7310))
- **fix(db):** tolerate unavailable virtual table modules in stats ([#7313](https://github.com/diegosouzapw/OmniRoute/pull/7313)) — thanks @megamen32
- **fix(router-eval):** retained-optimization gate cleanup ([#7318](https://github.com/diegosouzapw/OmniRoute/pull/7318)) — thanks @KooshaPari
- **fix(ci):** Coverage job timeout 10->20min (lcov reporter pushed it past the old cap) ([#7342](https://github.com/diegosouzapw/OmniRoute/pull/7342))
- **fix(electron):** normalize hashed standalone externals ([#7353](https://github.com/diegosouzapw/OmniRoute/pull/7353)) — thanks @tianrking
- **fix(db):** stop a 'latest' path segment from disabling backups and migrations ([#7359](https://github.com/diegosouzapw/OmniRoute/pull/7359)) — thanks @danscMax
- **fix(branding):** regenerate raster favicons — white mark was shipped without its gradient tile ([#7390](https://github.com/diegosouzapw/OmniRoute/pull/7390)) — thanks @vzts
- **fix(test):** skip real DNS writes in MITM dynamic-import test ([#7398](https://github.com/diegosouzapw/OmniRoute/pull/7398)) — thanks @HouMinXi
- **fix(antigravity):** streaming passthrough for non-streaming clients ([#7408](https://github.com/diegosouzapw/OmniRoute/pull/7408)) — thanks @HouMinXi
- **fix(build):** align engines.node with SUPPORTED_NODE_RANGE (#7446) ([#7490](https://github.com/diegosouzapw/OmniRoute/pull/7490)) — thanks @alltomatos
- **fix(api):** await params in Agent Bridge DNS route (Next.js 16) (#7271) ([#7492](https://github.com/diegosouzapw/OmniRoute/pull/7492)) — thanks @alltomatos
- **fix(dashboard):** show Obsidian context source card ([#7500](https://github.com/diegosouzapw/OmniRoute/pull/7500)) — thanks @DKotsyuba
- **fix(ci):** fetch full base history in pr-test-policy (shallow graft broke merge-base) ([#7501](https://github.com/diegosouzapw/OmniRoute/pull/7501))
- **fix(sse):** preserve chat quota across mixed windows ([#7504](https://github.com/diegosouzapw/OmniRoute/pull/7504)) — thanks @webmasterarbez
- **fix(oauth):** surface sanitized device-code error instead of a generic 500 ([#7511](https://github.com/diegosouzapw/OmniRoute/pull/7511)) — thanks @danscMax
- **fix(oauth):** repair qwen + codebuddy-cn device-code endpoints ([#7517](https://github.com/diegosouzapw/OmniRoute/pull/7517)) — thanks @danscMax
- **fix(mitm):** strip trailing assistant prefill to prevent upstream Anthropic 400 errors ([#7520](https://github.com/diegosouzapw/OmniRoute/pull/7520)) — thanks @chirag127
- **fix(codex):** Test probe uses a ChatGPT-account-supported model (#7521) ([#7524](https://github.com/diegosouzapw/OmniRoute/pull/7524))
- **fix(codex):** validate refresh_token on import before persisting (#7522) ([#7525](https://github.com/diegosouzapw/OmniRoute/pull/7525))
- **fix(codex):** non-stream chat 502 'Response body is already used' (single-reader peek) ([#7526](https://github.com/diegosouzapw/OmniRoute/pull/7526))
- **fix(oauth):** surface tunnel hint when Codex OAuth runs on a remote host (#7523) ([#7527](https://github.com/diegosouzapw/OmniRoute/pull/7527))
- **fix(sse):** preserve custom tool output images ([#7540](https://github.com/diegosouzapw/OmniRoute/pull/7540)) — thanks @loulanyue
- **fix(combo):** failover when upstream SSE is truncated mid-lifecycle ([#7545](https://github.com/diegosouzapw/OmniRoute/pull/7545)) — thanks @Chewji9875
- **fix(dashboard):** prefer public endpoint URLs ([#7547](https://github.com/diegosouzapw/OmniRoute/pull/7547)) — thanks @nguyenha935
- **fix(cli):** refresh runtime detection accurately ([#7552](https://github.com/diegosouzapw/OmniRoute/pull/7552)) — thanks @nguyenha935
- **fix(ui):** improve React Flow dark theme ([#7553](https://github.com/diegosouzapw/OmniRoute/pull/7553)) — thanks @nguyenha935
- **fix(i18n):** treat **MISSING** sync placeholders as absent in EN fallback (#7258) ([#7556](https://github.com/diegosouzapw/OmniRoute/pull/7556))
- **fix(cli):** Windows cert check/uninstall key off the real CA identity, not a hardcoded legacy host (#7275) ([#7557](https://github.com/diegosouzapw/OmniRoute/pull/7557))
- **fix(sse):** feed compression pipeline the authoritative vision capability (#7237) ([#7560](https://github.com/diegosouzapw/OmniRoute/pull/7560))
- **fix(dashboard):** providers model-name filter matches live/synced catalog (#7250) ([#7561](https://github.com/diegosouzapw/OmniRoute/pull/7561))
- **fix(db):** pre-init sql.js WASM ahead of any getDbInstance() consumer (#7288) ([#7562](https://github.com/diegosouzapw/OmniRoute/pull/7562))
- **fix(dashboard):** resolve costs page 500 from out-of-scope t() in TopListCard (#7272) ([#7564](https://github.com/diegosouzapw/OmniRoute/pull/7564))
- **fix(dashboard):** surface rate-limit warning on 429 chat-probe (#7284) ([#7565](https://github.com/diegosouzapw/OmniRoute/pull/7565))
- **fix(sse):** lazy-load playwright in claudeTurnstileSolver (#7265) ([#7566](https://github.com/diegosouzapw/OmniRoute/pull/7566))
- **fix(sse):** combo failover for OpenAI streams truncated without finish_reason (#7285) ([#7568](https://github.com/diegosouzapw/OmniRoute/pull/7568))
- **fix(cli):** reuse win32-aware locateCommand in tool-detector (#7279) ([#7569](https://github.com/diegosouzapw/OmniRoute/pull/7569))
- **fix(codex):** #7536 check content-type before touching response.body in peek ([#7570](https://github.com/diegosouzapw/OmniRoute/pull/7570))
- **fix(sse):** stop dropping tool_search and leaking OpenAI-only params in Responses->Chat translation ([#7571](https://github.com/diegosouzapw/OmniRoute/pull/7571))
- **fix(api):** resolve provider display name and dedup byModel on normalized key (#7534, #7535) ([#7573](https://github.com/diegosouzapw/OmniRoute/pull/7573))
- **fix(mitm):** route Claude Code standalone MITM traffic ([#7574](https://github.com/diegosouzapw/OmniRoute/pull/7574)) — thanks @dongwook-chan
- **fix(sse):** stop per-byte enumeration of binary image bytes in log redaction (#7297) ([#7576](https://github.com/diegosouzapw/OmniRoute/pull/7576))
- **fix(sse):** split effort/reasoning suffix off pinned cursor model ids (#7289) ([#7577](https://github.com/diegosouzapw/OmniRoute/pull/7577))
- **fix(chatgpt-web):** recognize update_content.messages[] celsius WS frames (#7357) ([#7578](https://github.com/diegosouzapw/OmniRoute/pull/7578))
- **fix(sse):** 401 model-not-supported lockout + sticky quota-exhausted release (#7268, #7387) ([#7580](https://github.com/diegosouzapw/OmniRoute/pull/7580))
- **fix(antigravity):** allow cloudcode envelope through messages guard ([#7582](https://github.com/diegosouzapw/OmniRoute/pull/7582)) — thanks @dongwook-chan
- **fix(sse):** sanitize empty-signature thinking blocks + hoist strict-provider system messages ([#7583](https://github.com/diegosouzapw/OmniRoute/pull/7583))
- **fix(sse):** honor per-model targetFormat override for zai/glm-coding-apikey (#7364) ([#7584](https://github.com/diegosouzapw/OmniRoute/pull/7584))
- **fix(sse):** clamp glm-4.6v max_tokens to the 32768 ceiling (#7364) ([#7585](https://github.com/diegosouzapw/OmniRoute/pull/7585))
- **fix(cli):** log Codex Responses WebSocket history/usage per logical turn, not per connection ([#7588](https://github.com/diegosouzapw/OmniRoute/pull/7588))
- **fix(providers):** derive static model catalogs for search providers from searchTypes ([#7589](https://github.com/diegosouzapw/OmniRoute/pull/7589))
- **fix(stream-readiness):** bump timeout for heavy Claude-format reasoning replicas ([#7612](https://github.com/diegosouzapw/OmniRoute/pull/7612)) — thanks @herjarsa
- **fix(translator):** synthesize tool call chunks from response.completed batched output ([#7613](https://github.com/diegosouzapw/OmniRoute/pull/7613)) — thanks @ekinnee
- **fix(embeddings):** add lmstudio to embedding provider registry ([#7614](https://github.com/diegosouzapw/OmniRoute/pull/7614)) — thanks @ekinnee
- **fix(combo):** auto-clear stale session pins and emit recovery hints on combo exhaustion ([#7625](https://github.com/diegosouzapw/OmniRoute/pull/7625)) — thanks @herjarsa
- **fix(providers):** unify connection and routing flows ([#7629](https://github.com/diegosouzapw/OmniRoute/pull/7629)) — thanks @nguyenha935
- **fix(db):** dedupe bulk-imported proxies by full credential tuple (#7594) ([#7644](https://github.com/diegosouzapw/OmniRoute/pull/7644)) — thanks @alltomatos
- **fix(api):** allow text-to-image on dual-modality models + revive HuggingFace image host ([#7648](https://github.com/diegosouzapw/OmniRoute/pull/7648)) — thanks @danscMax
- **fix(stryker):** add Microsoft Designer test to tap.testFiles ([#7659](https://github.com/diegosouzapw/OmniRoute/pull/7659))
- **fix(dashboard):** cut UI import chain from connection persist module (CI shard base-red) ([#7677](https://github.com/diegosouzapw/OmniRoute/pull/7677))
- **fix(perplexity-web):** stop empty-content responses from live schematized SSE ([#6955](https://github.com/diegosouzapw/OmniRoute/pull/6955)) — thanks @artickc
- **fix(dashboard):** make quota cards container responsive ([#7027](https://github.com/diegosouzapw/OmniRoute/pull/7027)) — thanks @xz-dev
- **fix(nvidia):** restore GLM-5.2 reasoning on NIM (#7215) ([#7296](https://github.com/diegosouzapw/OmniRoute/pull/7296)) — thanks @backryun
- **fix(i18n):** complete Vietnamese dashboard localization and runtime fixes ([#7493](https://github.com/diegosouzapw/OmniRoute/pull/7493)) — thanks @nguyenha935
- **fix(providers):** migrate muse-spark-web from GraphQL to WebSocket protocol ([#7528](https://github.com/diegosouzapw/OmniRoute/pull/7528)) — thanks @Ajeesh25353646
- **fix(combo):** expose computed context_length via /api/combos for accurate OC plugin display ([#7633](https://github.com/diegosouzapw/OmniRoute/pull/7633)) — thanks @herjarsa
- **fix(api):** enumerate tiered auto combo endpoints in /api/combos/auto ([#7662](https://github.com/diegosouzapw/OmniRoute/pull/7662)) — thanks @ekinnee
- **fix(dashboard):** topology reflects connection health + clears finished requests ([#7672](https://github.com/diegosouzapw/OmniRoute/pull/7672)) — thanks @danscMax
- **fix(kimi-coding):** capture and replay reasoning for thinking-mode turns ([#7673](https://github.com/diegosouzapw/OmniRoute/pull/7673)) — thanks @xz-dev
- **fix(ci):** merge-train --fast mirrors test:unit subdir allowlist ([#7688](https://github.com/diegosouzapw/OmniRoute/pull/7688))
- **fix(sse):** start credential-health sweep at boot so stale web sessions recover ([#7689](https://github.com/diegosouzapw/OmniRoute/pull/7689)) — thanks @danscMax
- **fix(cursor):** discover models via official CLI command ([#7692](https://github.com/diegosouzapw/OmniRoute/pull/7692)) — thanks @makcimbx
- **fix(quota):** fix antigravity/agy multi-model quota skipping in combos ([#7695](https://github.com/diegosouzapw/OmniRoute/pull/7695)) — thanks @irvandikky
- **fix(usage):** preserve account identity history ([#7700](https://github.com/diegosouzapw/OmniRoute/pull/7700)) — thanks @xz-dev
- **fix(db):** update proxies on password rotation ([#7707](https://github.com/diegosouzapw/OmniRoute/pull/7707)) — thanks @floze-the-genius
- **fix(providers):** correct Chutes registry baseUrl (#7621) ([#7708](https://github.com/diegosouzapw/OmniRoute/pull/7708))
- **fix(routing):** strip prompt_cache_key for NVIDIA NIM (#7617) ([#7709](https://github.com/diegosouzapw/OmniRoute/pull/7709))
- **fix(providers):** degrade Arena (lmarena) cookie validation redirect to unsupported (#7542) ([#7710](https://github.com/diegosouzapw/OmniRoute/pull/7710))
- **fix(claude-web):** unify Turnstile/executor/fast-path User-Agents behind one fingerprint (#7548) ([#7711](https://github.com/diegosouzapw/OmniRoute/pull/7711))
- **fix(sse):** authenticate CLIProxyAPI fallback/passthrough legs with a dedicated credential (#7645) ([#7712](https://github.com/diegosouzapw/OmniRoute/pull/7712))
- **fix(sse):** proactively refresh Grok Build OAuth token before dispatch (#7610) ([#7715](https://github.com/diegosouzapw/OmniRoute/pull/7715))
- **fix(providers):** classify ambiguous Mistral 401 instead of hard auth error (#7638) ([#7718](https://github.com/diegosouzapw/OmniRoute/pull/7718))
- **fix(security):** bump adm-zip >=0.6.0 + exact host matching in mitm DNS test ([#7732](https://github.com/diegosouzapw/OmniRoute/pull/7732))
- **fix(icons):** fall back to Stepfun Mono when Color component is absent … ([#7743](https://github.com/diegosouzapw/OmniRoute/pull/7743)) — thanks @Dan-ex-hub
- **fix(stream):** suppress `</think>` close marker for Responses API clients ([#7747](https://github.com/diegosouzapw/OmniRoute/pull/7747)) — thanks @xz-dev
- **fix(sse):** wire settings.wildcardAliases into model resolution (#7693) ([#7748](https://github.com/diegosouzapw/OmniRoute/pull/7748))
- **fix(authz):** classify forge/jcode CLI settings routes as LOCAL_ONLY (#7263) ([#7749](https://github.com/diegosouzapw/OmniRoute/pull/7749))
- **fix(routing):** honor eye-icon hidden models for no-auth providers in auto-combo (#7620) ([#7750](https://github.com/diegosouzapw/OmniRoute/pull/7750))
- **fix(sse):** persist rotated Gemini web-session cookies via onCredentialsRefreshed (#7676) ([#7751](https://github.com/diegosouzapw/OmniRoute/pull/7751))
- **fix(docs):** heal release-green docs drift + eslint any-suppression drift (#7253) ([#7755](https://github.com/diegosouzapw/OmniRoute/pull/7755))
- **fix(mcp):** copy undici into dist/node_modules to prevent hollow-package shadowing crash (#7701) ([#7756](https://github.com/diegosouzapw/OmniRoute/pull/7756))
- **fix(packaging):** move fumadocs-mdx to devDependencies (#7661) ([#7757](https://github.com/diegosouzapw/OmniRoute/pull/7757))
- **fix(ci):** build API-only smoke workflows backend-only to fix dast-smoke timeouts (#7226) ([#7758](https://github.com/diegosouzapw/OmniRoute/pull/7758))
- **fix(cli):** load DATA_DIR/server.env as fallback for .env on Electron migration (#7302) ([#7759](https://github.com/diegosouzapw/OmniRoute/pull/7759))
- **fix(cli):** split outboundUrlGuard's DB helpers so setup-opencode packages cleanly (#7682) ([#7760](https://github.com/diegosouzapw/OmniRoute/pull/7760))
- **fix(notion-web):** production-ready labels, multi-workspace, inference, usage (FINAL) ([#7768](https://github.com/diegosouzapw/OmniRoute/pull/7768)) — thanks @artickc
- **fix(kimi):** expose K3 reasoning effort levels ([#7776](https://github.com/diegosouzapw/OmniRoute/pull/7776)) — thanks @xz-dev
- **fix(compression):** apply compression combo assignments to routing combos ([#7779](https://github.com/diegosouzapw/OmniRoute/pull/7779)) — thanks @ekinnee
- **fix(i18n):** regenerate Polish UI locale from English ([#7782](https://github.com/diegosouzapw/OmniRoute/pull/7782)) — thanks @leszek3737
- **fix(combo):** retry transient errors in pipeline strategy ([#7794](https://github.com/diegosouzapw/OmniRoute/pull/7794)) — thanks @AndrianBalanescu
- **fix(quality):** register nvidia-quota-phase1 and service-provider-plugin-registry in stryker tap.testFiles ([#7796](https://github.com/diegosouzapw/OmniRoute/pull/7796))
- **fix(stream):** synthesize terminal finish_reason chunk when upstream omits it (#7800) ([#7804](https://github.com/diegosouzapw/OmniRoute/pull/7804)) — thanks @AndrianBalanescu
- **fix(plugins):** 5 bugs on the plugin path (3 Windows-only, 2 all-platform) ([#7806](https://github.com/diegosouzapw/OmniRoute/pull/7806)) — thanks @tmone
- **fix(cli):** register ESM alias resolver for @/ paths under global install ([#7808](https://github.com/diegosouzapw/OmniRoute/pull/7808)) — thanks @rafaumeu
- **fix(auth):** gate invalid-key check on isRequireApiKeyEnabled for embeddings and web-fetch (#7785) ([#7810](https://github.com/diegosouzapw/OmniRoute/pull/7810)) — thanks @AndrianBalanescu
- **fix(ci):** repair release regressions exposed by clean runs ([#7812](https://github.com/diegosouzapw/OmniRoute/pull/7812)) — thanks @backryun
- **fix(rerank):** add voyage format adapter for request/response translation (#7809) ([#7813](https://github.com/diegosouzapw/OmniRoute/pull/7813)) — thanks @AndrianBalanescu
- **fix(antigravity):** attempt onboarding when projectId is empty (#5193 regression of #2541) ([#7815](https://github.com/diegosouzapw/OmniRoute/pull/7815)) — thanks @rafaumeu
- **fix(stream):** emit terminal SSE frames on mid-stream upstream failure (#7699) ([#7816](https://github.com/diegosouzapw/OmniRoute/pull/7816)) — thanks @AndrianBalanescu
- **fix(sse):** strip orphaned tool_use before antigravity/Vertex Claude dispatch (#7752) ([#7822](https://github.com/diegosouzapw/OmniRoute/pull/7822))
- **fix(translator):** sanitize tool_result.tool_use_id symmetrically with tool_use.id (#7705) ([#7823](https://github.com/diegosouzapw/OmniRoute/pull/7823))
- **fix(oauth):** require chatgptUserId agreement for Codex account dedup (#7737) ([#7825](https://github.com/diegosouzapw/OmniRoute/pull/7825))
- **fix(db):** purge in-memory key-health state when a provider connection is deleted (#7740) ([#7826](https://github.com/diegosouzapw/OmniRoute/pull/7826))
- **fix(compression):** keep a retrievable preamble instead of a bare CCR marker (#7746) ([#7827](https://github.com/diegosouzapw/OmniRoute/pull/7827))
- **fix(db):** log fatal boot-time SQLite driver-cascade failure before propagating (#7773) ([#7828](https://github.com/diegosouzapw/OmniRoute/pull/7828))
- **fix(docker):** repair tls-client-node native binary after --ignore-scripts (#7802) ([#7829](https://github.com/diegosouzapw/OmniRoute/pull/7829))
- **fix(auth):** restore TICK_MS in tokenHealthCheck (ReferenceError on startup) ([#7830](https://github.com/diegosouzapw/OmniRoute/pull/7830))
- **fix(cli):** fix Windows CLI detection false negatives (#7753, #7774) ([#7831](https://github.com/diegosouzapw/OmniRoute/pull/7831))
- **fix(docs):** document CREDENTIAL_REDACTION_ENABLED and GHE_COPILOT_OAUTH_CLIENT_ID (#7793) ([#7833](https://github.com/diegosouzapw/OmniRoute/pull/7833))
- **fix(dashboard):** fix collapsed quota card session/weekly order (#7764) ([#7834](https://github.com/diegosouzapw/OmniRoute/pull/7834))
- **fix(dashboard):** mirror connection-row action-icon spacing under RTL (#7680) ([#7835](https://github.com/diegosouzapw/OmniRoute/pull/7835))
- **fix:** avoid cmd.exe spawn on Windows by using os.hostname() before execSync fallback ([#7841](https://github.com/diegosouzapw/OmniRoute/pull/7841)) — thanks @tientien17
- **fix(usage):** harden account identity reconciliation ([#7843](https://github.com/diegosouzapw/OmniRoute/pull/7843)) — thanks @xz-dev
- **fix(cli):** use rundll32 instead of cmd.exe for Windows browser fallback in dashboard command ([#7844](https://github.com/diegosouzapw/OmniRoute/pull/7844)) — thanks @tientien17
- **fix:** add native lifecycle-aware health endpoint ([#7852](https://github.com/diegosouzapw/OmniRoute/pull/7852)) — thanks @RaviTharuma
- **fix:** reserve chat admission before body parsing ([#7853](https://github.com/diegosouzapw/OmniRoute/pull/7853)) — thanks @RaviTharuma
- **fix:** bound quadratic session-dedup memory growth ([#7855](https://github.com/diegosouzapw/OmniRoute/pull/7855)) — thanks @RaviTharuma
- **fix(compression):** enable OmniGlyph for Claude Fable 5 ([#7863](https://github.com/diegosouzapw/OmniRoute/pull/7863)) — thanks @enjoyer-hub
- **fix(notion-web):** add browser fingerprint headers to reduce Cloudflare challenges ([#7864](https://github.com/diegosouzapw/OmniRoute/pull/7864)) — thanks @HassiyYT
- **fix(mitm):** gate Agent Bridge Repair on sudo password (#7836) ([#7865](https://github.com/diegosouzapw/OmniRoute/pull/7865)) — thanks @skutanjir
- **fix(rerank):** honor the connection's pinned proxy on rerank calls (#7350) ([#7867](https://github.com/diegosouzapw/OmniRoute/pull/7867))
- **fix(compression):** skip CCR on tool outputs to preserve agent loop ([#7869](https://github.com/diegosouzapw/OmniRoute/pull/7869)) — thanks @herjarsa
- **fix(vision-bridge):** reroute auto/ prefix to vision model when images present ([#7871](https://github.com/diegosouzapw/OmniRoute/pull/7871)) — thanks @herjarsa
- **fix(opencode-plugin):** support separate management read token ([#7885](https://github.com/diegosouzapw/OmniRoute/pull/7885)) — thanks @RaviTharuma
- **fix(sse):** CC bridge loses OpenAI-format image input (OpenCode/Kilo/Cline → AgentRouter) ([#7888](https://github.com/diegosouzapw/OmniRoute/pull/7888))
- **fix(combo):** strip boolean reasoning field for opencode-go providers ([#7891](https://github.com/diegosouzapw/OmniRoute/pull/7891)) — thanks @AndrianBalanescu
- **fix(notion-web):** accept OpenAI content-parts arrays in transcript ([#7896](https://github.com/diegosouzapw/OmniRoute/pull/7896)) — thanks @artickc
- **fix(notion-web):** reuse threadId across OpenAI multi-turn (no new chat each request) ([#7900](https://github.com/diegosouzapw/OmniRoute/pull/7900)) — thanks @artickc
- **fix(gemini):** strip OpenAI "strict" tool-schema keyword for Antigravity ([#7901](https://github.com/diegosouzapw/OmniRoute/pull/7901)) — thanks @Witroch4
- **fix(antigravity):** collect native functionCall parts in SSE collector ([#7902](https://github.com/diegosouzapw/OmniRoute/pull/7902)) — thanks @Witroch4
- **fix(sse):** recover invalid Anthropic thinking signatures once ([#7906](https://github.com/diegosouzapw/OmniRoute/pull/7906)) — thanks @insoln
- **fix(resilience):** don't cool down accounts or trip the breaker on client aborts ([#7908](https://github.com/diegosouzapw/OmniRoute/pull/7908)) — thanks @insoln
- **fix(dashboard):** preserve quota cutoff drafts ([#7909](https://github.com/diegosouzapw/OmniRoute/pull/7909)) — thanks @hydraxman
- **fix(providers):** treat public-host 302 as valid in Gemini Web connection test (#7859) ([#7917](https://github.com/diegosouzapw/OmniRoute/pull/7917))
- **fix(providers):** read reasoning_text in Claude-format response translator (#7856) ([#7919](https://github.com/diegosouzapw/OmniRoute/pull/7919))
- **fix(dashboard):** safely render structured error objects in Request Logs detail (#7845) ([#7920](https://github.com/diegosouzapw/OmniRoute/pull/7920))
- **fix(dashboard):** repair monaco deep import broken by 0.56 exports map (#7897) ([#7922](https://github.com/diegosouzapw/OmniRoute/pull/7922))
- **fix(autostart):** adopt 9Router VBS startup to suppress console flash on Windows ([#7925](https://github.com/diegosouzapw/OmniRoute/pull/7925)) — thanks @tientien17
- **fix(translators):** normalize TitleCase tool names for non-Anthropic models ([#7926](https://github.com/diegosouzapw/OmniRoute/pull/7926)) — thanks @nramabad
- **fix(api):** resolve local provider models via dashboard catalog fallback ([#7927](https://github.com/diegosouzapw/OmniRoute/pull/7927)) — thanks @ekinnee
- **fix(auto):** pool accounts by provider model ([#7928](https://github.com/diegosouzapw/OmniRoute/pull/7928)) — thanks @adrianaryaputra
- **fix(perplexity-web):** multi-step empty content + advanced-quota cooldown ([#7930](https://github.com/diegosouzapw/OmniRoute/pull/7930)) — thanks @artickc
- **fix(ccr):** resolve principal via OMNIROUTE_API_KEY env var on stdio MCP transport ([#7932](https://github.com/diegosouzapw/OmniRoute/pull/7932)) — thanks @ekinnee
- **fix(combo):** context-aware fallback ignores model_context_override ([#7933](https://github.com/diegosouzapw/OmniRoute/pull/7933)) — thanks @tmone
- **fix(i18n):** preserve remaining Vietnamese localization ([#7935](https://github.com/diegosouzapw/OmniRoute/pull/7935)) — thanks @nguyenha935
- **fix(mitm):** gate Agent Bridge DNS and Trust Cert on sudo password (#7938) ([#7939](https://github.com/diegosouzapw/OmniRoute/pull/7939)) — thanks @skutanjir
- **fix(providers):** route iflytek/sparkdesk to Spark's OpenAI-compatible host ([#7942](https://github.com/diegosouzapw/OmniRoute/pull/7942)) — thanks @FenjuFu
- **fix(sse):** preserve parallel_tool_calls for GPT-5.6 delegation under Codex Responses Lite (#7821) ([#7957](https://github.com/diegosouzapw/OmniRoute/pull/7957))
- **fix(providers):** copilot-m365-web fails loudly on empty turns + tier-aware enterprise invocation (#7858, #7870) ([#7958](https://github.com/diegosouzapw/OmniRoute/pull/7958))
- **fix(providers):** treat unreliable web-cookie /models probe status as unsupported, not valid (#7857) ([#7959](https://github.com/diegosouzapw/OmniRoute/pull/7959))
- **fix(api):** add amazon-q to the static model catalog (#7820) ([#7960](https://github.com/diegosouzapw/OmniRoute/pull/7960))
- **fix:** parse Gemini 429 RetryInfo.retryDelay for model lockout (#7940) ([#7961](https://github.com/diegosouzapw/OmniRoute/pull/7961))
- **fix(quality):** tolerate ESLint's trailing unpruned-suppressions text in validate-release-green (#7837) ([#7962](https://github.com/diegosouzapw/OmniRoute/pull/7962))
- **fix(cli):** translate missing sqlite bindings error into actionable guidance (#7868) ([#7963](https://github.com/diegosouzapw/OmniRoute/pull/7963))
- **fix(cli):** spawn opencode.cmd shim with shell:true on win32 (#7913) ([#7964](https://github.com/diegosouzapw/OmniRoute/pull/7964))
- **fix(dashboard):** correct block-extra-Claude-usage toggle copy to match quarantine behavior (#7918) ([#7965](https://github.com/diegosouzapw/OmniRoute/pull/7965))
- **fix(api):** classify /api/acp/agents as loopback-only (#7948) ([#7966](https://github.com/diegosouzapw/OmniRoute/pull/7966))
- **fix(auth):** restore configurable HEALTHCHECK_BATCH_SIZE dropped by #7719 (#7875) ([#7970](https://github.com/diegosouzapw/OmniRoute/pull/7970))
- **fix(test):** widen ratelimit-admission pollUntil deadline to 10s (#7842) ([#7971](https://github.com/diegosouzapw/OmniRoute/pull/7971))
- **fix(pricing):** clarify disabled automatic sync status ([#7972](https://github.com/diegosouzapw/OmniRoute/pull/7972)) — thanks @RaviTharuma
- **fix(combo):** exempt content_filter from empty-content detection ([#7973](https://github.com/diegosouzapw/OmniRoute/pull/7973)) — thanks @HouMinXi
- **fix(embeddings):** support secure multimodal inputs ([#7978](https://github.com/diegosouzapw/OmniRoute/pull/7978)) — thanks @RaviTharuma
- **fix(resilience):** cap exactCooldownMs against maxCooldownMs (#7940) ([#7980](https://github.com/diegosouzapw/OmniRoute/pull/7980)) — thanks @ekinnee
- **fix(electron):** derive macOS Helper name from execPath to remove 2nd Dock icon (#7941) ([#8002](https://github.com/diegosouzapw/OmniRoute/pull/8002))
- **fix(chatcore):** report string-reason client aborts as 499, not 502 (#7907) ([#8011](https://github.com/diegosouzapw/OmniRoute/pull/8011)) — thanks @Long-Feeds
- **fix(models):** drop generic catalog siblings of specialty surfaces (#8015) ([#8021](https://github.com/diegosouzapw/OmniRoute/pull/8021)) — thanks @RaviTharuma
- **fix(models):** stop inventing chat capabilities for specialty surfaces (#8016) ([#8022](https://github.com/diegosouzapw/OmniRoute/pull/8022)) — thanks @RaviTharuma
- **fix(capabilities):** resolve models.dev specialty rows across provider keys (#8017) ([#8023](https://github.com/diegosouzapw/OmniRoute/pull/8023)) — thanks @RaviTharuma
- **fix(models):** attach models.dev pricing to GET /v1/models entries (#8018) ([#8025](https://github.com/diegosouzapw/OmniRoute/pull/8025)) — thanks @RaviTharuma
- **fix(grok-cli):** require full auth.json on OAuth paste import (#7610) ([#8027](https://github.com/diegosouzapw/OmniRoute/pull/8027)) — thanks @RaviTharuma
- **fix(grok-cli):** sanitize function_call_output before Grok Build dispatch (#7611) ([#8030](https://github.com/diegosouzapw/OmniRoute/pull/8030)) — thanks @RaviTharuma
- **fix(sse):** bound forwarded response headers ([#8041](https://github.com/diegosouzapw/OmniRoute/pull/8041)) — thanks @insoln
- **fix(sse):** replace spoofable .includes() PromptQL issuer check with hostname comparison (#8029) ([#8042](https://github.com/diegosouzapw/OmniRoute/pull/8042))
- **fix(sse):** bound Codex SSE peek read with per-read timeout (#8020) ([#8043](https://github.com/diegosouzapw/OmniRoute/pull/8043))
- **fix(cli):** stop double-prefixing combo model ids in opencode plugin static catalog (#7976) ([#8047](https://github.com/diegosouzapw/OmniRoute/pull/8047))
- **fix(antigravity):** scope 404 model-not-found lockout to exact model + bare-model autopick ([#8050](https://github.com/diegosouzapw/OmniRoute/pull/8050)) — thanks @AndrianBalanescu
- **fix:** repair pre-existing red gates on the release/v3.8.49 tip ([#8055](https://github.com/diegosouzapw/OmniRoute/pull/8055))
- **fix(oauth):** honor connectionId on token refresh so email-less providers don't duplicate ([#8062](https://github.com/diegosouzapw/OmniRoute/pull/8062)) — thanks @insoln
- **fix:** classify Google quota exhaustion responses ([#8071](https://github.com/diegosouzapw/OmniRoute/pull/8071)) — thanks @rafaumeu
- **fix(providers):** refresh duckduckgo-web catalog to current Duck.ai wire ids (#8000) ([#8079](https://github.com/diegosouzapw/OmniRoute/pull/8079))
- **fix(security):** decouple request PII redaction from injection mode ([#8102](https://github.com/diegosouzapw/OmniRoute/pull/8102)) — thanks @RaviTharuma
- **fix(providers):** discover live AGY models ([#8123](https://github.com/diegosouzapw/OmniRoute/pull/8123)) — thanks @adevwithpurpose
- **fix(guardrails):** align INPUT_SANITIZER request masking gate (#8093) ([#8124](https://github.com/diegosouzapw/OmniRoute/pull/8124)) — thanks @RaviTharuma
- **fix(providers):** refresh Baidu ERNIE and Qianfan website URLs (#6271) ([#8128](https://github.com/diegosouzapw/OmniRoute/pull/8128)) — thanks @TrackCrewGalore
- **fix(stream):** add logging to empty catch blocks in stream error handling ([#8143](https://github.com/diegosouzapw/OmniRoute/pull/8143)) — thanks @chirag127
- **fix(sse):** stop Codex/Responses sanitizer turning system image_url into output_text (#8089) ([#8147](https://github.com/diegosouzapw/OmniRoute/pull/8147))
- **fix(db):** register SIGHUP handler and stop force-killing server on win32 stop paths (#8045) ([#8148](https://github.com/diegosouzapw/OmniRoute/pull/8148))
- **fix(providers):** add missing poe registry baseUrl entry (#8082) ([#8149](https://github.com/diegosouzapw/OmniRoute/pull/8149))
- **fix(routing):** anchor quota cache on globalThis for cross-chunk consistency (#8065) ([#8150](https://github.com/diegosouzapw/OmniRoute/pull/8150))
- **fix(responses):** close namespace round-trip for Responses-Chat translation (#7936) ([#8151](https://github.com/diegosouzapw/OmniRoute/pull/8151)) — thanks @RCrushMe
- **fix(oauth):** warn instead of silently opening unreachable localhost redirect for LAN-IP Codex/xAI/Grok OAuth (#8046) ([#8152](https://github.com/diegosouzapw/OmniRoute/pull/8152))
- **fix(db):** stop closing the sql.js singleton in getDbInstance() probe/reopen (#7494) ([#8153](https://github.com/diegosouzapw/OmniRoute/pull/8153))
- **fix(sse):** run compression pipeline per turn in Codex Responses WS bridge (#8052) ([#8154](https://github.com/diegosouzapw/OmniRoute/pull/8154))
- **fix(cli):** merge node bin dir into CLI healthcheck PATH for codex detection (#8036) ([#8156](https://github.com/diegosouzapw/OmniRoute/pull/8156))
- **fix(sse):** anonymous fingerprint fallback for keyless Pollinations image gen (#8085) ([#8157](https://github.com/diegosouzapw/OmniRoute/pull/8157))
- **fix(cli):** surface the real spawn error in process supervisor (#8091) ([#8158](https://github.com/diegosouzapw/OmniRoute/pull/8158))
- **fix:** strip internal reasoning placeholder from user-visible content (#8081) ([#8162](https://github.com/diegosouzapw/OmniRoute/pull/8162)) — thanks @Dingding-leo
- **fix(combos):** expose synced reasoning-effort variants in Combo Builder model picker (#8072) ([#8165](https://github.com/diegosouzapw/OmniRoute/pull/8165)) — thanks @Dingding-leo
- **fix(windows):** add windowsHide to all child process spawns (#8131) ([#8167](https://github.com/diegosouzapw/OmniRoute/pull/8167)) — thanks @Dingding-leo
- **fix(cursor):** bridge native tools to client calls ([#8171](https://github.com/diegosouzapw/OmniRoute/pull/8171)) — thanks @makcimbx
- **fix(security):** bound JWT-extraction regexes to prevent polynomial ReDoS (CodeQL #754/#755/#756) ([#8173](https://github.com/diegosouzapw/OmniRoute/pull/8173))
- **fix(#8141):** log pending request counter decrement failures ([#8179](https://github.com/diegosouzapw/OmniRoute/pull/8179)) — thanks @rafaumeu
- **fix(#8135):** suppress sql.js build warning via non-analyzable dynamic import ([#8184](https://github.com/diegosouzapw/OmniRoute/pull/8184)) — thanks @rafaumeu
- **fix(#8093):** align INPUT_SANITIZER_ENABLED default to true across all docs ([#8185](https://github.com/diegosouzapw/OmniRoute/pull/8185)) — thanks @rafaumeu
- **fix(combo):** skip remaining same-provider targets on 401/403 auth failure ([#8195](https://github.com/diegosouzapw/OmniRoute/pull/8195)) — thanks @rafaumeu
- **fix(compression):** make memo key model-independent for non-vision engines ([#8196](https://github.com/diegosouzapw/OmniRoute/pull/8196)) — thanks @rafaumeu
- **fix(resilience):** add max/step to NumberField for provider cooldown inputs (#8107) ([#8203](https://github.com/diegosouzapw/OmniRoute/pull/8203)) — thanks @rafaumeu
- **fix(providers):** fix Azure AI Foundry multi-model discovery and per-deployment connection testing (#8174) ([#8206](https://github.com/diegosouzapw/OmniRoute/pull/8206)) — thanks @not-knope
- **fix(logs):** stop the async-EPIPE log-flood loop at its ignition point ([#8207](https://github.com/diegosouzapw/OmniRoute/pull/8207)) — thanks @Tasogarre
- **fix(sse):** surface OpenRouter mid-stream error chunks instead of a false empty success ([#8210](https://github.com/diegosouzapw/OmniRoute/pull/8210)) — thanks @hartmark
- **fix(sse):** Gemini malformed function-call handling + tool_choice translation ([#8211](https://github.com/diegosouzapw/OmniRoute/pull/8211)) — thanks @hartmark
- **fix(sse):** tool-incapable provider handling (AI Horde + Responses content-collapse scoping) ([#8212](https://github.com/diegosouzapw/OmniRoute/pull/8212)) — thanks @hartmark
- **fix(sse):** Gemini TPM/RPD quota classification + combo cooldown-wait resilience ([#8213](https://github.com/diegosouzapw/OmniRoute/pull/8213)) — thanks @hartmark
- **fix(services):** resolve and record a real pid when adopting a service ([#8218](https://github.com/diegosouzapw/OmniRoute/pull/8218)) — thanks @seanford
- **fix(memory):** resolve remote embedding dimensions for reindex (#8074) ([#8220](https://github.com/diegosouzapw/OmniRoute/pull/8220)) — thanks @Prudhvivuda
- **fix(dashboard):** correct machine-translated Korean UI strings in ko.json ([#8224](https://github.com/diegosouzapw/OmniRoute/pull/8224)) — thanks @MichaelYcJo
- **fix(devin-cli):** refresh shared model catalog ([#8227](https://github.com/diegosouzapw/OmniRoute/pull/8227)) — thanks @backryun
- **fix(claude-web):** align session transport and fallback ([#8230](https://github.com/diegosouzapw/OmniRoute/pull/8230)) — thanks @backryun
- **fix:** restore OAuth auto-refresh for gemini-cli connections ([#8232](https://github.com/diegosouzapw/OmniRoute/pull/8232)) — thanks @seanford
- **fix:** normalize Codex URLs and dashboard regressions ([#8233](https://github.com/diegosouzapw/OmniRoute/pull/8233)) — thanks @nguyenha935
- **fix(api):** narrow claudeClassifierCompat auto trigger so stop_sequences alone no longer short-circuits (#8189) ([#8236](https://github.com/diegosouzapw/OmniRoute/pull/8236))
- **fix(gemini):** drop HARM_CATEGORY_CIVIC_INTEGRITY from the default Gemini safety settings (#8231) ([#8238](https://github.com/diegosouzapw/OmniRoute/pull/8238))
- **fix(backend):** word-boundary-safe tool-result truncation in lite compression mode (#8169) ([#8239](https://github.com/diegosouzapw/OmniRoute/pull/8239))
- **fix(providers):** filter unsupported family-fallback candidates against the provider catalog (#8134) ([#8240](https://github.com/diegosouzapw/OmniRoute/pull/8240))
### 📚 Docs
- **docs(quality):** codify retry policy per runner + release-level drift rule (WS5.4/WS5.5) ([#7107](https://github.com/diegosouzapw/OmniRoute/pull/7107))
- **docs(troubleshooting):** document Avast/AVG README.md false positive (#5946) ([#7295](https://github.com/diegosouzapw/OmniRoute/pull/7295))
- **docs(perf):** add per-endpoint p50/p95/p99 latency + cost budget reference ([#7336](https://github.com/diegosouzapw/OmniRoute/pull/7336)) — thanks @KooshaPari
- **docs:** refresh revoked Discord invite + WhatsApp Brasil link ([#7604](https://github.com/diegosouzapw/OmniRoute/pull/7604))
- **docs(readme):** animated SVG for the 4-tier auto-fallback cascade ([#7615](https://github.com/diegosouzapw/OmniRoute/pull/7615))
- **docs:** sync provider count to 259 (unblocks docs-counts strict gate) ([#7616](https://github.com/diegosouzapw/OmniRoute/pull/7616))
- **docs(readme):** animate pool + combo ASCII blocks as SMIL SVG diagrams ([#7626](https://github.com/diegosouzapw/OmniRoute/pull/7626))
- **docs(readme):** animate CLI command list + compression flow as SMIL SVGs ([#7637](https://github.com/diegosouzapw/OmniRoute/pull/7637))
- **docs(readme):** replace free-tier budget mockup with animated SMIL card ([#7665](https://github.com/diegosouzapw/OmniRoute/pull/7665))
- **docs(readme):** standardize all README tables to full content width ([#7666](https://github.com/diegosouzapw/OmniRoute/pull/7666))
- **docs:** fix three stale references failing the fabricated-docs gate ([#7728](https://github.com/diegosouzapw/OmniRoute/pull/7728))
- **docs(readme):** unified animated card system — audited v3.8.49 numbers, style contract across all cards, 5 new cards + rebuilt terminal ([#7769](https://github.com/diegosouzapw/OmniRoute/pull/7769))
- **docs(readme):** add Kimi (Moonshot AI) official supporter section ([#7770](https://github.com/diegosouzapw/OmniRoute/pull/7770))
- **docs(getting-started):** reorder Verify It Works before IDE/CLI setup + add examples ([#7790](https://github.com/diegosouzapw/OmniRoute/pull/7790)) — thanks @swingtempo
- **docs(readme):** audit every number against the live code + refresh contributors and acknowledgments ([#7795](https://github.com/diegosouzapw/OmniRoute/pull/7795))
- **docs(readme):** evolve supporter section into sub2api-style Sponsors section ([#7799](https://github.com/diegosouzapw/OmniRoute/pull/7799))
- **docs(readme):** contributors 360+ -> 350+ (audited) ([#7803](https://github.com/diegosouzapw/OmniRoute/pull/7803))
- **docs(i18n):** refresh Polish README and fix relative links ([#7807](https://github.com/diegosouzapw/OmniRoute/pull/7807)) — thanks @leszek3737
- **docs:** add general Web Cookie provider setup guide ([#7881](https://github.com/diegosouzapw/OmniRoute/pull/7881)) — thanks @arpit-jaiswal-dev
- **docs(guides):** document Kaspersky PDM behavioral false positive on the Desktop installer (#7903) ([#7923](https://github.com/diegosouzapw/OmniRoute/pull/7923))
- **docs:** document npm install ERESOLVE/peer/deprecated warnings as harmless (fixes #7951) ([#7988](https://github.com/diegosouzapw/OmniRoute/pull/7988)) — thanks @Dingding-leo
- **docs:** fix Docker IPv6 connection reset with -p 127.0.0.1 bind (fixes #7722) ([#7989](https://github.com/diegosouzapw/OmniRoute/pull/7989)) — thanks @Dingding-leo
- **docs(readme):** Kimi partner tracking links (aff=omniroute) + first-Brazilian-project line + disclosure ([#8028](https://github.com/diegosouzapw/OmniRoute/pull/8028))
- **docs:** add AgentRouter multi-provider routing troubleshooting ([#8049](https://github.com/diegosouzapw/OmniRoute/pull/8049)) — thanks @leninejunior
- **docs(security):** correct prompt-injection severity table + heuristic-limitations disclaimer (#8097) ([#8113](https://github.com/diegosouzapw/OmniRoute/pull/8113)) — thanks @rafaumeu
- **docs(ops):** publish public branching and release model (#7627) ([#8129](https://github.com/diegosouzapw/OmniRoute/pull/8129)) — thanks @c4usal
- **docs(i18n):** full Russian README rewrite ([#8217](https://github.com/diegosouzapw/OmniRoute/pull/8217)) — thanks @MonteNegroX
- **docs(readme):** re-audit numbers, fix table scroll, refresh contributors ([#8243](https://github.com/diegosouzapw/OmniRoute/pull/8243))
### 🧪 Tests & Quality
- **test(build):** derive pack-artifact closures for all npm-shipped entrypoints (#7065 class) ([#7081](https://github.com/diegosouzapw/OmniRoute/pull/7081))
- **test(dashboard):** dedicated regression guard for #6815 density guarantee ([#7291](https://github.com/diegosouzapw/OmniRoute/pull/7291))
- **test(ci):** make #6634 selfref guard hermetic — read file from disk, no git ref ([#7327](https://github.com/diegosouzapw/OmniRoute/pull/7327))
- **test(ci):** mock route bridge surfaces error message, not raw stack ([#7354](https://github.com/diegosouzapw/OmniRoute/pull/7354))
- **test(ci):** static body in codex e2e mock route bridge (CodeQL #737) ([#7558](https://github.com/diegosouzapw/OmniRoute/pull/7558))
- **test(ci):** exact-line assert in grok-build config test (CodeQL #740/#741) ([#7628](https://github.com/diegosouzapw/OmniRoute/pull/7628))
- **test(codex):** cover image tool output replay (#7698) ([#7704](https://github.com/diegosouzapw/OmniRoute/pull/7704)) — thanks @dongwook-chan
- **test(security):** exact SAN-entry match in mitm leaf-cert test (CodeQL #746) ([#7824](https://github.com/diegosouzapw/OmniRoute/pull/7824))
- **test(#8140):** verify keepalive interval cleanup on disconnect, resolve, and reject ([#8190](https://github.com/diegosouzapw/OmniRoute/pull/8190)) — thanks @rafaumeu
### 🔧 Chores / CI
- **chore(release):** gate the sync-back push on release-green --quick (WS0.3) ([#7083](https://github.com/diegosouzapw/OmniRoute/pull/7083))
- **chore(ci):** gate hygiene — secrets baseline 0, semgrep drop, hadolint (WS6/D3 + WS1.7) ([#7099](https://github.com/diegosouzapw/OmniRoute/pull/7099))
- **chore(ops):** runner-box janitor + operations runbook (WS3.3) ([#7115](https://github.com/diegosouzapw/OmniRoute/pull/7115))
- **chore(ci):** promote test:vitest:ui to blocking (suite green after #7127) ([#7147](https://github.com/diegosouzapw/OmniRoute/pull/7147))
- **chore(ci):** stop dependabot proposing typescript majors — peer-blocked by typescript-eslint ([#7306](https://github.com/diegosouzapw/OmniRoute/pull/7306))
- **chore(release):** script the 0a.0b PR re-home with a verified read-back ([#7312](https://github.com/diegosouzapw/OmniRoute/pull/7312))
- **chore(quality):** tighten the coverage ratchet to the CI's real numbers ([#7326](https://github.com/diegosouzapw/OmniRoute/pull/7326))
- **chore(ci):** make the Electron Windows leg advisory with bash stderr capture (first-run failure diagnosis) ([#7340](https://github.com/diegosouzapw/OmniRoute/pull/7340))
- **chore(deps):** bump actions/setup-node from 6 to 7 ([#7348](https://github.com/diegosouzapw/OmniRoute/pull/7348))
- **chore(deps):** bump codecov/codecov-action ([#7350](https://github.com/diegosouzapw/OmniRoute/pull/7350))
- **ci(release-green):** add a main-green arm to detect when main goes red ([#7355](https://github.com/diegosouzapw/OmniRoute/pull/7355))
- **chore(deps):** bump github/codeql-action/analyze from 4.37.0 to 4.37.1 ([#7641](https://github.com/diegosouzapw/OmniRoute/pull/7641))
- **chore(deps):** bump github/codeql-action/init from 4.37.0 to 4.37.1 ([#7642](https://github.com/diegosouzapw/OmniRoute/pull/7642))
- **chore(quality):** register #6672 test in stryker tap.testFiles (base-red unblock) ([#7652](https://github.com/diegosouzapw/OmniRoute/pull/7652))
- **chore(release):** merge-train box-speed suite + --fast mode ([#7670](https://github.com/diegosouzapw/OmniRoute/pull/7670))
- **chore:** [defer] fix embedded CLIProxyAPI config handling ([#6877](https://github.com/diegosouzapw/OmniRoute/pull/6877)) — thanks @professional-ALFIE
- **chore:** [defer] fix(grok): align responses tool-call shape for grok models ([#6937](https://github.com/diegosouzapw/OmniRoute/pull/6937)) — thanks @CitrusIce
- **chore:** [needs-vps] feat(dashboard): add per-operator quota row visibility on usage tab ([#7251](https://github.com/diegosouzapw/OmniRoute/pull/7251))
- **chore:** [defer] feat(combo): universal cooldown-aware retry & auto-strategy combo-ref guard ([#7301](https://github.com/diegosouzapw/OmniRoute/pull/7301)) — thanks @ViFigueiredo
- **chore:** Completing Arabic language ([#7686](https://github.com/diegosouzapw/OmniRoute/pull/7686)) — thanks @mustafa-phd
- **chore:** IC2: Cache provider connections by ID + provider nodes ([#7744](https://github.com/diegosouzapw/OmniRoute/pull/7744)) — thanks @oyi77
- **chore:** [Emergency Fix] fix(build): repair release build blockers ([#7772](https://github.com/diegosouzapw/OmniRoute/pull/7772)) — thanks @backryun
- **chore:** [Part 1/3]refactor(qwen): replace legacy Qwen Code and remove OAuth provider ([#7866](https://github.com/diegosouzapw/OmniRoute/pull/7866)) — thanks @backryun
- **chore:** [Part 3/3] feat(qwen): add regional Alibaba and Qwen Cloud providers ([#7882](https://github.com/diegosouzapw/OmniRoute/pull/7882)) — thanks @backryun
- **chore:** Preserve supported Responses behavior in Chat translation ([#7894](https://github.com/diegosouzapw/OmniRoute/pull/7894)) — thanks @JxnLexn
- **chore:** Restore Responses API custom tool calls ([#7905](https://github.com/diegosouzapw/OmniRoute/pull/7905)) — thanks @JxnLexn
- **chore:** Hide internal reasoning replay placeholders ([#7912](https://github.com/diegosouzapw/OmniRoute/pull/7912)) — thanks @JxnLexn
- **chore(deps):** bump js-yaml, brace-expansion, shell-quote, tar (security) ([#7915](https://github.com/diegosouzapw/OmniRoute/pull/7915))
- **chore:** i18n(zh-TW): complete Traditional Chinese (Taiwan) translation overhaul ([#8024](https://github.com/diegosouzapw/OmniRoute/pull/8024)) — thanks @lunkerchen
- **chore:** i18n: bring 40 locales to full parity with en.json ([#8031](https://github.com/diegosouzapw/OmniRoute/pull/8031)) — thanks @nguyenha935
- **chore(deps):** resolve 7 open Dependabot alerts via npm overrides ([#8066](https://github.com/diegosouzapw/OmniRoute/pull/8066))
- **chore(deps):** resolve 3 more Dependabot alerts (dompurify, fast-xml-parser, sharp) ([#8069](https://github.com/diegosouzapw/OmniRoute/pull/8069))
- **chore(dashboard):** reframe Kimi partnership as "Open Source Friends" ([#8117](https://github.com/diegosouzapw/OmniRoute/pull/8117))
- **chore(quality):** fix 2 pre-existing lint/suppression drift issues ([#8209](https://github.com/diegosouzapw/OmniRoute/pull/8209)) — thanks @hartmark
- **chore:** add K3banner-1.png banner asset ([#8242](https://github.com/diegosouzapw/OmniRoute/pull/8242))
### 🔀 Other
- [needs-vps] fix(electron): materialize Turbopack hashed-module symlinks during packaging (#6724, #6594) ([#6794](https://github.com/diegosouzapw/OmniRoute/pull/6794)) — thanks @huohua-dev
- [codex] Keep mode-pack weights consistent in auto fallback ranking ([#7008](https://github.com/diegosouzapw/OmniRoute/pull/7008)) — thanks @KooshaPari
- Explain effective auto-combo scoring weights ([#7087](https://github.com/diegosouzapw/OmniRoute/pull/7087)) — thanks @KooshaPari
- [needs-vps] fix(dashboard): add vision-capability toggle for custom OpenAI-compatible models ([#7124](https://github.com/diegosouzapw/OmniRoute/pull/7124))
- [needs-vps] fix(dashboard): align onboarding tier content ([#7125](https://github.com/diegosouzapw/OmniRoute/pull/7125)) — thanks @Wibias
- Use OpenAI chunks for early chat keepalives ([#7136](https://github.com/diegosouzapw/OmniRoute/pull/7136)) — thanks @KooshaPari
- Fix Codex Responses compression analytics ([#7273](https://github.com/diegosouzapw/OmniRoute/pull/7273)) — thanks @JxnLexn
- Add cache-aligned Live Zone compression ([#7280](https://github.com/diegosouzapw/OmniRoute/pull/7280)) — thanks @JxnLexn
- Fix routed target request parameters ([#7323](https://github.com/diegosouzapw/OmniRoute/pull/7323)) — thanks @JxnLexn
- deps: bump electron from 43.1.0 to 43.1.1 in /electron ([#7349](https://github.com/diegosouzapw/OmniRoute/pull/7349))
- deps: bump the development group with 8 updates ([#7351](https://github.com/diegosouzapw/OmniRoute/pull/7351))
- deps: bump the production group across 1 directory with 12 updates ([#7352](https://github.com/diegosouzapw/OmniRoute/pull/7352))
- Add per-connection Provider Quota visibility ([#7360](https://github.com/diegosouzapw/OmniRoute/pull/7360)) — thanks @JxnLexn
- Stream model health probes for slow providers ([#7377](https://github.com/diegosouzapw/OmniRoute/pull/7377)) — thanks @JxnLexn
- Refresh NVIDIA free metadata and detect catalog drift ([#7378](https://github.com/diegosouzapw/OmniRoute/pull/7378)) — thanks @JxnLexn
- Reject invalid output token budgets ([#7379](https://github.com/diegosouzapw/OmniRoute/pull/7379)) — thanks @JxnLexn
- Honor provider proxies for The Old LLM Vercel blocks ([#7380](https://github.com/diegosouzapw/OmniRoute/pull/7380)) — thanks @JxnLexn
- Restore proxy navigation and sidebar accordion state ([#7381](https://github.com/diegosouzapw/OmniRoute/pull/7381)) — thanks @JxnLexn
- Expose proxy controls for no-auth providers ([#7419](https://github.com/diegosouzapw/OmniRoute/pull/7419)) — thanks @JxnLexn
- Add reasoning-based model and effort routing ([#7607](https://github.com/diegosouzapw/OmniRoute/pull/7607)) — thanks @JxnLexn
- **refactor(sse):** extract per-provider token-refresh functions from tokenRefresh.ts ([#7817](https://github.com/diegosouzapw/OmniRoute/pull/7817))
- **deps:** bump the production group with 8 updates ([#7897](https://github.com/diegosouzapw/OmniRoute/pull/7897)) — thanks @dependabot[bot]
- **deps:** bump the development group with 5 updates ([#7898](https://github.com/diegosouzapw/OmniRoute/pull/7898)) — thanks @dependabot[bot]
- **refactor(antigravity):** align official clients and callable catalog ([#8013](https://github.com/diegosouzapw/OmniRoute/pull/8013)) — thanks @backryun
- **refactor(compression):** extract resolveHeadroomDetail to keep dispatchCompression under the complexity gate ([#8058](https://github.com/diegosouzapw/OmniRoute/pull/8058))
- **deps:** bump next from 16.2.10 to 16.2.11 ([#8235](https://github.com/diegosouzapw/OmniRoute/pull/8235)) — thanks @dependabot[bot]
### 🩹 Direct release-branch fixes (no PR — authorized base-red sweep, 2026-07-18)
- **fix(base-red):** full-suite realignment after the 102-PR merge campaign: two real production fixes (legacy `refresh_token` column healed before its index is created; `shouldSkipCloudSyncInitialization` no longer swaps its `(env, argv)` arguments) plus 13 test files, goldens, provider counts, and env docs realigned to the live-validated behavior of the merged PRs.
### 🙌 Contributors
Thanks to everyone whose work landed in v3.8.49:
| Contributor | PRs / Issues |
| --- | --- |
| [@adevwithpurpose](https://github.com/adevwithpurpose) | #8123 |
| [@adrianaryaputra](https://github.com/adrianaryaputra) | #7928 |
| [@Ajeesh25353646](https://github.com/Ajeesh25353646) | #7528 |
| [@alltomatos](https://github.com/alltomatos) | #7041, #7042, #7164, #7277, #7490, #7492, #7644 |
| [@alvaretto](https://github.com/alvaretto) | #8077, #8161, #8170 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #7794, #7804, #7810, #7813, #7816, #7891, #8050 |
| [@apoapostolov](https://github.com/apoapostolov) | #8127 |
| [@arpit-jaiswal-dev](https://github.com/arpit-jaiswal-dev) | #7881 |
| [@artickc](https://github.com/artickc) | #6955, #7204, #7696, #7768, #7896, #7900, #7911, #7930, #7994, #8006 |
| [@Arul-](https://github.com/Arul-) | #7878 |
| [@backryun](https://github.com/backryun) | #7296, #7314, #7358, #7531, #7687, #7772, #7812, #7866, #7874, #7882, #7914, #8013, #8225, #8226, #8227, #8230 |
| [@c4usal](https://github.com/c4usal) | #8129 |
| [@Capslockb](https://github.com/Capslockb) | #7892 |
| [@Chewji9875](https://github.com/Chewji9875) | #7545 |
| [@chirag127](https://github.com/chirag127) | #7520, #8143 |
| [@CitrusIce](https://github.com/CitrusIce) | #6937, #6938 |
| [@Dan-ex-hub](https://github.com/Dan-ex-hub) | #7743 |
| [@danscMax](https://github.com/danscMax) | #7359, #7511, #7517, #7648, #7656, #7672, #7689 |
| [@dependabot](https://github.com/dependabot) | #7897, #7898, #8235 |
| [@Dingding-leo](https://github.com/Dingding-leo) | #7988, #7989, #8162, #8165, #8167 |
| [@DKotsyuba](https://github.com/DKotsyuba) | #7500 |
| [@dongwook-chan](https://github.com/dongwook-chan) | #7574, #7582, #7704 |
| [@ekinnee](https://github.com/ekinnee) | #7613, #7614, #7662, #7779, #7927, #7932, #7980 |
| [@enjoyer-hub](https://github.com/enjoyer-hub) | #7863 |
| [@fenix007](https://github.com/fenix007) | #7171, #7399 |
| [@FenjuFu](https://github.com/FenjuFu) | #7942 |
| [@floze-the-genius](https://github.com/floze-the-genius) | #7707 |
| [@growab](https://github.com/growab) | #7062 |
| [@guanbear](https://github.com/guanbear) | #7028 |
| [@hartmark](https://github.com/hartmark) | #8208, #8209, #8210, #8211, #8212, #8213 |
| [@HassiyYT](https://github.com/HassiyYT) | #7864 |
| [@herjarsa](https://github.com/herjarsa) | #7612, #7625, #7633, #7869, #7871 |
| [@HouMinXi](https://github.com/HouMinXi) | #7035, #7129, #7290, #7398, #7408, #7973 |
| [@hppsc1215](https://github.com/hppsc1215) | #7546 |
| [@huohua-dev](https://github.com/huohua-dev) | #6794 |
| [@hydraxman](https://github.com/hydraxman) | #7909 |
| [@insoln](https://github.com/insoln) | #7906, #7908, #8041, #8054, #8062 |
| [@irvandikky](https://github.com/irvandikky) | #7695 |
| [@isiahw1](https://github.com/isiahw1) | #7555 |
| [@JxnLexn](https://github.com/JxnLexn) | #6993, #7154, #7177, #7269, #7273, #7280, #7281, #7282, #7323, #7360, #7377, #7378, #7379, #7380, #7381, #7419, #7607, #7894, #7905, #7912, #8008, #8009, #8010 |
| [@KooshaPari](https://github.com/KooshaPari) | #7008, #7087, #7093, #7128, #7130, #7136, #7315, #7318, #7334, #7336 |
| [@leninejunior](https://github.com/leninejunior) | #8049 |
| [@leszek3737](https://github.com/leszek3737) | #7782, #7807 |
| [@Long-Feeds](https://github.com/Long-Feeds) | #8011 |
| [@loulanyue](https://github.com/loulanyue) | #7540 |
| [@lunkerchen](https://github.com/lunkerchen) | #8024 |
| [@makcimbx](https://github.com/makcimbx) | #7692, #8171 |
| [@megamen32](https://github.com/megamen32) | #7313 |
| [@MichaelYcJo](https://github.com/MichaelYcJo) | #8224 |
| [@mikolaj92](https://github.com/mikolaj92) | #6973 |
| [@MonteNegroX](https://github.com/MonteNegroX) | #8217 |
| [@Moseyuh333](https://github.com/Moseyuh333) | #7781 |
| [@MrFadiAi](https://github.com/MrFadiAi) | #7073 |
| [@mustafa-phd](https://github.com/mustafa-phd) | #7686 |
| [@nguyenha935](https://github.com/nguyenha935) | #7493, #7547, #7552, #7553, #7629, #7935, #8031, #8233 |
| [@not-knope](https://github.com/not-knope) | #8206 |
| [@nramabad](https://github.com/nramabad) | #7926 |
| [@oyi77](https://github.com/oyi77) | #6917, #6918, #6919, #6920, #6921, #6923, #7032, #7045, #7046, #7066, #7070, #7178, #7719, #7744, #7787, #7893, #8219 |
| [@professional-ALFIE](https://github.com/professional-ALFIE) | #6877 |
| [@Prudhvivuda](https://github.com/Prudhvivuda) | #8220 |
| [@rafaumeu](https://github.com/rafaumeu) | #6979, #6982, #6983, #6987, #6988, #7001, #7808, #7815, #8071, #8113, #8179, #8184, #8185, #8190, #8195, #8196, #8203 |
| [@RaviTharuma](https://github.com/RaviTharuma) | #7852, #7853, #7855, #7862, #7885, #7972, #7978, #8021, #8022, #8023, #8025, #8027, #8030, #8102, #8124 |
| [@RCrushMe](https://github.com/RCrushMe) | #8151 |
| [@rushsinging](https://github.com/rushsinging) | #7256 |
| [@seanford](https://github.com/seanford) | #8218, #8232 |
| [@SeaXen](https://github.com/SeaXen) | #7063, #7264, #7294 |
| [@Securiteru](https://github.com/Securiteru) | #7683 |
| [@skutanjir](https://github.com/skutanjir) | #7865, #7939 |
| [@swingtempo](https://github.com/swingtempo) | #7790 |
| [@Tasogarre](https://github.com/Tasogarre) | #7861, #8207 |
| [@thepigdestroyer](https://github.com/thepigdestroyer) | #7497 |
| [@tianrking](https://github.com/tianrking) | #7353 |
| [@tientien17](https://github.com/tientien17) | #7841, #7844, #7925 |
| [@tmone](https://github.com/tmone) | #7806, #7933 |
| [@TrackCrewGalore](https://github.com/TrackCrewGalore) | #8128 |
| [@ViFigueiredo](https://github.com/ViFigueiredo) | #7301 |
| [@vzts](https://github.com/vzts) | #7390 |
| [@webmasterarbez](https://github.com/webmasterarbez) | #7504 |
| [@Wibias](https://github.com/Wibias) | #7125 |
| [@Witroch4](https://github.com/Witroch4) | #7901, #7902 |
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #8122 |
| [@xier2012](https://github.com/xier2012) | #7036, #7050, #7052, #7053, #7056, #7059, #7060, #7061, #7166, #7299 |
| [@xz-dev](https://github.com/xz-dev) | #7004, #7012, #7027, #7673, #7700, #7747, #7776, #7843 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
---
## [3.8.48] — 2026-07-13
> ⚠️ **Hotfix release.** The published npm package for 3.8.47 crashed on every boot ([#7065](https://github.com/diegosouzapw/OmniRoute/issues/7065)) and was deprecated — **3.8.48 is the first installable release of the v3.8.47 cycle**, so everything listed under [3.8.47] below ships here.
### 🐛 Bug Fixes
- **fix(build):** ship `dist/head-response-guard.cjs` in the npm tarball — the prepublish prune allowlist lacked it, so every `omniroute` boot of the published 3.8.47 crashed with `ERR_MODULE_NOT_FOUND` (3rd occurrence of this class after tls-options/3.8.41); now allowlisted, enforced by `check:pack-artifact`, and guarded by a closure test that derives every `server-ws.mjs` sibling import ([#7065](https://github.com/diegosouzapw/OmniRoute/issues/7065), [#7040](https://github.com/diegosouzapw/OmniRoute/issues/7040))
- **fix(build):** Electron Windows packaging — the better-sqlite3 Electron-ABI rebuild now spawns `npx.cmd` through a shell (Node's CVE-2024-27980 hardening made the shell-less spawn fail with `status null` on Windows runners, breaking the v3.8.47 desktop build)
- **fix(ci):** Sonar quality gate zeroed on new code — the coverage lcov now reaches the scanner at `coverage/lcov.info` (it read 0% on every scan), the async `isCloudEnabled()` gate in the Kiro auto-import route is awaited (cloud sync ran even when disabled), the dead `structuredClone` fallback in the reasoning-split clone is a real JSON fallback, the codex executor handles the async `reader.cancel()` rejection, deterministic `localeCompare` sorts, a path-traversal guard in `classify-pr-changes.mjs`, and the Docker better-sqlite3 rebuild uses npm's bundled node-gyp instead of `npx --yes`
- **chore(ci):** the Sonar quality gate is informational (`sonar.qualitygate.wait=false`) while the org's SonarCloud plan cannot associate the tuned "OmniRoute way" gate (coverage ≥60 aligned with the repo floor)
---
## [3.8.47] — 2026-07-13
_Living section — bullets land here as PRs merge into `release/v3.8.47` (parallel-cycle model; cycle opened at the v3.8.46 release freeze). Finalized at the v3.8.47 release._
@@ -907,7 +189,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
- **fix(resilience):** a combo step "pinned" to one fingerprint account (mimocode/mcode/opencode multi-account providers) never actually resolved to that account, so it couldn't fail over when the pinned account was depleted ([#6696](https://github.com/diegosouzapw/OmniRoute/issues/6696), relates #6612) — the combo builder UI encodes an account pin as a composite connectionId (`${rowId}|fp|${fingerprint}`, `src/lib/combos/builderOptions.ts`), but `expandTargetsByFingerprints()` (`open-sse/services/combo/fingerprintExpansion.ts`) looked that composite string up directly in `connectionById` (keyed by real DB row ids), got `undefined`, and passed the target through unchanged, still carrying the bogus composite id — so downstream credential resolution could never match it either. `expandTargetsByFingerprints()` now splits the `|fp|` composite id back into the real connection row id + the pinned fingerprint (new `splitFingerprintPin()` helper) before any lookup, resolving the target to the real connectionId (with the pinned fingerprint carried on the new `pinnedFingerprint` field) instead of the inert composite string. Regression guard: `tests/unit/combo-fingerprint-pin-6696.test.ts`.
- **fix(api):** Responses passthrough emitted event-only SSE frames (no `data:` line) for every dropped commentary event, breaking the OpenAI Python SDK's `sse.json()` parser ([#6561](https://github.com/diegosouzapw/OmniRoute/issues/6561)), follow-up to #6199/#6232 — the commentary-drop `continue;` branches in `open-sse/utils/stream.ts` skipped the `data:` line for a dropped commentary event but never cleared the already-buffered `event:` line for that same frame, so the next blank line flushed the stale `event:` line alone. Both drop sites now call `clearPendingPassthroughEvent()` before `continue`, discarding the buffered prefix along with the dropped payload; the commentary-drop decision itself was extracted into a new `open-sse/utils/responsesCommentaryDrop.ts` so the fix does not grow the frozen `stream.ts`. Regression guard: `tests/unit/responses-commentary-event-frame-6561.test.ts` (realistic `event:\ndata:\n\n` frames — the existing #6199 test only used bare `data:` lines and never exercised this path).
- **fix(compression):** `/api/compression/preview`'s top-level `originalTokens`/`compressedTokens` diverged from `engineBreakdown[0]`'s counts for the same single-engine run (tiktoken outer counts vs the `JSON.stringify(...).length/4` estimate per engine), worst on small inputs. A new `reconcileSingleEngineTokens()` overwrites the single-engine breakdown entry with the outer, more accurate figures; multi-step pipeline breakdowns are left untouched ([#6488](https://github.com/diegosouzapw/OmniRoute/issues/6488)). Regression guard: `tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts`.
- **fix(resilience):** account selection could pick an account already out of quota upstream on every credentialed route except `chat`/`codex` ([#6686](https://github.com/diegosouzapw/OmniRoute/issues/6686)) — `getProviderCredentials()` (`src/sse/services/auth.ts`) only skips a connection when a _local cache_ already flags it exhausted (`isQuotaExhaustedForRequest`/`src/domain/quotaCache.ts`); it never itself calls the registered upstream `QuotaFetcher`. Only `getProviderCredentialsWithQuotaPreflight()` performs that live upstream check, and it was wired into exactly 2 call sites (`src/sse/handlers/chat.ts`, `src/app/api/internal/codex-responses-ws/route.ts`) — every other credentialed route (`rerank`, `images/generations`, `images/edits`, `audio/transcriptions|speech|translations`, `videos/generations`, `music/generations`, `ocr`, `providers/[provider]/embeddings`, `providers/[provider]/images/generations`, `web/fetch`, `moderations`, `search`) called the plain, cache-only selector, so an account whose cache entry was never populated (e.g. its first request landed on one of these routes) could be selected even at 0% quota remaining. Those 14 call sites now go through `getProviderCredentialsWithQuotaPreflight()` instead, matching chat/codex coverage. Regression guard: `tests/unit/issue-6686-quota-preflight-coverage.test.ts` (static check that none of the routes call the plain selector anymore + a behavioral check that the preflight-aware selector blocks a 100%-used account).
- **fix(resilience):** account selection could pick an account already out of quota upstream on every credentialed route except `chat`/`codex` ([#6686](https://github.com/diegosouzapw/OmniRoute/issues/6686)) — `getProviderCredentials()` (`src/sse/services/auth.ts`) only skips a connection when a *local cache* already flags it exhausted (`isQuotaExhaustedForRequest`/`src/domain/quotaCache.ts`); it never itself calls the registered upstream `QuotaFetcher`. Only `getProviderCredentialsWithQuotaPreflight()` performs that live upstream check, and it was wired into exactly 2 call sites (`src/sse/handlers/chat.ts`, `src/app/api/internal/codex-responses-ws/route.ts`) — every other credentialed route (`rerank`, `images/generations`, `images/edits`, `audio/transcriptions|speech|translations`, `videos/generations`, `music/generations`, `ocr`, `providers/[provider]/embeddings`, `providers/[provider]/images/generations`, `web/fetch`, `moderations`, `search`) called the plain, cache-only selector, so an account whose cache entry was never populated (e.g. its first request landed on one of these routes) could be selected even at 0% quota remaining. Those 14 call sites now go through `getProviderCredentialsWithQuotaPreflight()` instead, matching chat/codex coverage. Regression guard: `tests/unit/issue-6686-quota-preflight-coverage.test.ts` (static check that none of the routes call the plain selector anymore + a behavioral check that the preflight-aware selector blocks a 100%-used account).
- **fix(api):** `reasoning_content` (extended-thinking text) was silently dropped from `/v1/chat/completions` SSE on the `claude-web` and `v0-vercel-web` executors ([#6662](https://github.com/diegosouzapw/OmniRoute/issues/6662)) — every chunk builder in both adapters hardcoded `delta: { content: ... }` with no reasoning path, unlike the established pattern already used by `default.ts`/`deepseek-web.ts`/`bedrock.ts` and the real-Anthropic-API `claude-to-openai.ts` translator (`thinking_delta``reasoning_content`). `v0-vercel-web.ts` now forwards an upstream `delta.reasoning_content` field (streaming and non-streaming) the same way `deepseek-web.ts` does. `claude-web.ts`'s `buildClaudeStreamingResponse` now maps a `content_block_start`(`type: "thinking"`)/`content_block_delta`(`delta.thinking`) pair onto `delta.reasoning_content`, and `claude-web/payload.ts`'s `transformToClaude()` no longer hardcodes `thinking_mode: "off"` — a new `wantsExtendedThinking()` derives it from the request's `reasoning_effort`/`reasoning.effort`/`thinking.type` signal, so extended thinking can actually be requested. Regression guard: `tests/unit/issue-6662-repro.test.ts` (RED→GREEN for both adapters).
- **fix(api):** the compression config PUT schema now accepts `enableRenderers` for the RTK engine instead of rejecting the documented option (#6703, #6757 — thanks @alltomatos, with an independent duplicate fix from @chirag127 via #6756).
- **fix(api):** raised the provider `apiKey` length cap for cookie-based web providers, whose session-cookie credentials legitimately exceed the previous limit (#6715, #6759 — thanks @alltomatos).
@@ -977,68 +259,70 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
- **docs:** refresh `llm.txt` to the current project state (248 providers, 94 MCP tools / 30 scopes, 18 routing strategies, 12-factor Auto-Combo scoring, v3.8.47) and sync its 42 i18n mirrors; move the implemented design-system plan from the repo root to `docs/architecture/DESIGN_SYSTEM.md` rewritten as a reference doc.
- **chore(security):** scrub hardcoded live-instance credentials (API key + auth cookie + host URL) from the `tests/boundary/*.live.test.ts` files landed via #6786 — they now read `OMNIROUTE_TEST_BASE` / `OMNIROUTE_TEST_BEARER` / `OMNIROUTE_TEST_COOKIE` from the environment and stay gated behind `RUN_BOUNDARY_LIVE=1`.
### 🙌 Contributors
Thanks to everyone whose work landed in v3.8.47:
| Contributor | PRs / Issues |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [@AgentKiller45](https://github.com/AgentKiller45) | #6863, #6866 |
| [@alltomatos](https://github.com/alltomatos) | #6703, #6715, #6756, #6757, #6759, #6813, #6819, #6821 |
| [@andrewmunsell](https://github.com/andrewmunsell) | #6774, #6779, #6795 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #6828, #6829 |
| [@anhdiepmmk](https://github.com/anhdiepmmk) | direct commit / report |
| [@artickc](https://github.com/artickc) | #6363, #6763 |
| [@backryun](https://github.com/backryun) | #6280, #6675, #6862 |
| [@blackwell-systems](https://github.com/blackwell-systems) | #6838 |
| [@brick30llc-ctrl](https://github.com/brick30llc-ctrl) | #6944 |
| [@charleszolot](https://github.com/charleszolot) | #6571 |
| [@chirag127](https://github.com/chirag127) | #6402, #6458, #6460, #6461, #6463, #6515, #6519, #6523, #6534, #6546, #6577, #6643, #6644, #6645, #6646, #6769, #6804, #6868, #6869, #6870, #6871, #6883 |
| [@chy1211](https://github.com/chy1211) | direct commit / report |
| [@crochabe-cyber](https://github.com/crochabe-cyber) | #4013 |
| [@deadcoder0904](https://github.com/deadcoder0904) | #6665 |
| [@developerjillur](https://github.com/developerjillur) | direct commit / report |
| [@eidoog](https://github.com/eidoog) | direct commit / report |
| [@enjoyer-hub](https://github.com/enjoyer-hub) | #6647 |
| [@gdevenyi](https://github.com/gdevenyi) | direct commit / report |
| [@growab](https://github.com/growab) | #6867 |
| [@hajilok](https://github.com/hajilok) | #6126, #6833 |
| [@hamsa0x7](https://github.com/hamsa0x7) | #6317, #6318, #6338 |
| [@herjarsa](https://github.com/herjarsa) | direct commit / report |
| [@iamraydoan](https://github.com/iamraydoan) | #6798 |
| [@ianriizky](https://github.com/ianriizky) | #6072, #6538 |
| [@itiwant](https://github.com/itiwant) | direct commit / report |
| [@janeza2](https://github.com/janeza2) | #6308 |
| [@JxnLexn](https://github.com/JxnLexn) | #6776 |
| [@KooshaPari](https://github.com/KooshaPari) | #6611, #6632, #6856 |
| [@like3213934360-lab](https://github.com/like3213934360-lab) | direct commit / report |
| [@lucasjustinudin](https://github.com/lucasjustinudin) | direct commit / report |
| [@lunkerchen](https://github.com/lunkerchen) | #6320 |
| [@MikeTuev](https://github.com/MikeTuev) | #6586, #6830 |
| [@Moseyuh333](https://github.com/Moseyuh333) | #6294, #6728 |
| [@nowhats-br](https://github.com/nowhats-br) | direct commit / report |
| [@oyi77](https://github.com/oyi77) | #6907, #6926, #6929, #6946 |
| [@Pitchfork-and-Torch](https://github.com/Pitchfork-and-Torch) | #6747, #6791, #6792 |
| [@pizzav-xyz](https://github.com/pizzav-xyz) | #6648 |
| [@quanturbo](https://github.com/quanturbo) | #6780 |
| [@rafaumeu](https://github.com/rafaumeu) | #6813 |
| [@rafpigna](https://github.com/rafpigna) | #6574 |
| [@rucciva](https://github.com/rucciva) | #4125 |
| [@rushsinging](https://github.com/rushsinging) | #6807 |
| [@ryanngit](https://github.com/ryanngit) | direct commit / report |
| [@samir-abis](https://github.com/samir-abis) | direct commit / report |
| [@SeaXen](https://github.com/SeaXen) | direct commit / report |
| [@shabeer](https://github.com/shabeer) | direct commit / report |
| [@Squawk7777](https://github.com/Squawk7777) | #6565 |
| [@strangersp](https://github.com/strangersp) | #6587 |
| [@Thinkscape](https://github.com/Thinkscape) | direct commit / report |
| [@ThongAccount](https://github.com/ThongAccount) | #6625, #6649 |
| [@tjengbudi](https://github.com/tjengbudi) | #4009 |
| [@whale9820](https://github.com/whale9820) | direct commit / report |
| [@Witroch4](https://github.com/Witroch4) | #6753, #6762, #6790 |
| [@xz-dev](https://github.com/xz-dev) | #6323, #6330, #6702, #6714, #6727 |
| [@yinaoxiong](https://github.com/yinaoxiong) | #6805 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
| Contributor | PRs / Issues |
| --- | --- |
| [@AgentKiller45](https://github.com/AgentKiller45) | #6863, #6866 |
| [@alltomatos](https://github.com/alltomatos) | #6703, #6715, #6756, #6757, #6759, #6813, #6819, #6821 |
| [@andrewmunsell](https://github.com/andrewmunsell) | #6774, #6779, #6795 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #6828, #6829 |
| [@anhdiepmmk](https://github.com/anhdiepmmk) | direct commit / report |
| [@artickc](https://github.com/artickc) | #6363, #6763 |
| [@backryun](https://github.com/backryun) | #6280, #6675, #6862 |
| [@blackwell-systems](https://github.com/blackwell-systems) | #6838 |
| [@brick30llc-ctrl](https://github.com/brick30llc-ctrl) | #6944 |
| [@charleszolot](https://github.com/charleszolot) | #6571 |
| [@chirag127](https://github.com/chirag127) | #6402, #6458, #6460, #6461, #6463, #6515, #6519, #6523, #6534, #6546, #6577, #6643, #6644, #6645, #6646, #6769, #6804, #6868, #6869, #6870, #6871, #6883 |
| [@chy1211](https://github.com/chy1211) | direct commit / report |
| [@crochabe-cyber](https://github.com/crochabe-cyber) | #4013 |
| [@deadcoder0904](https://github.com/deadcoder0904) | #6665 |
| [@developerjillur](https://github.com/developerjillur) | direct commit / report |
| [@eidoog](https://github.com/eidoog) | direct commit / report |
| [@enjoyer-hub](https://github.com/enjoyer-hub) | #6647 |
| [@gdevenyi](https://github.com/gdevenyi) | direct commit / report |
| [@growab](https://github.com/growab) | #6867 |
| [@hajilok](https://github.com/hajilok) | #6126, #6833 |
| [@hamsa0x7](https://github.com/hamsa0x7) | #6317, #6318, #6338 |
| [@herjarsa](https://github.com/herjarsa) | direct commit / report |
| [@iamraydoan](https://github.com/iamraydoan) | #6798 |
| [@ianriizky](https://github.com/ianriizky) | #6072, #6538 |
| [@itiwant](https://github.com/itiwant) | direct commit / report |
| [@janeza2](https://github.com/janeza2) | #6308 |
| [@JxnLexn](https://github.com/JxnLexn) | #6776 |
| [@KooshaPari](https://github.com/KooshaPari) | #6611, #6632, #6856 |
| [@like3213934360-lab](https://github.com/like3213934360-lab) | direct commit / report |
| [@lucasjustinudin](https://github.com/lucasjustinudin) | direct commit / report |
| [@lunkerchen](https://github.com/lunkerchen) | #6320 |
| [@MikeTuev](https://github.com/MikeTuev) | #6586, #6830 |
| [@Moseyuh333](https://github.com/Moseyuh333) | #6294, #6728 |
| [@nowhats-br](https://github.com/nowhats-br) | direct commit / report |
| [@oyi77](https://github.com/oyi77) | #6907, #6926, #6929, #6946 |
| [@Pitchfork-and-Torch](https://github.com/Pitchfork-and-Torch) | #6747, #6791, #6792 |
| [@pizzav-xyz](https://github.com/pizzav-xyz) | #6648 |
| [@quanturbo](https://github.com/quanturbo) | #6780 |
| [@rafaumeu](https://github.com/rafaumeu) | #6813 |
| [@rafpigna](https://github.com/rafpigna) | #6574 |
| [@rucciva](https://github.com/rucciva) | #4125 |
| [@rushsinging](https://github.com/rushsinging) | #6807 |
| [@ryanngit](https://github.com/ryanngit) | direct commit / report |
| [@samir-abis](https://github.com/samir-abis) | direct commit / report |
| [@SeaXen](https://github.com/SeaXen) | direct commit / report |
| [@shabeer](https://github.com/shabeer) | direct commit / report |
| [@Squawk7777](https://github.com/Squawk7777) | #6565 |
| [@strangersp](https://github.com/strangersp) | #6587 |
| [@Thinkscape](https://github.com/Thinkscape) | direct commit / report |
| [@ThongAccount](https://github.com/ThongAccount) | #6625, #6649 |
| [@tjengbudi](https://github.com/tjengbudi) | #4009 |
| [@whale9820](https://github.com/whale9820) | direct commit / report |
| [@Witroch4](https://github.com/Witroch4) | #6753, #6762, #6790 |
| [@xz-dev](https://github.com/xz-dev) | #6323, #6330, #6702, #6714, #6727 |
| [@yinaoxiong](https://github.com/yinaoxiong) | #6805 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
---

View File

@@ -35,22 +35,22 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 290 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 250 LLM providers, auto-fallback.
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (95 files, 110 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 104 tools (42 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
| Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory |
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (95 files, 110 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 94 tools (34 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 30 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
| Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory |
Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point).
@@ -482,8 +482,8 @@ list` shows worktrees you didn't create, leave them alone. End every session wit
## Environment
- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules. This is the **only 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`).
- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules. This is the **only** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun.
- **Bun (build/dev script runner only)**: Bun `1.3.10` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression`. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the published runtime, or the test runners — those stay on Node. Any new Bun-invoking script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those 5 scripts with `bun: not found`).
- **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)

View File

@@ -103,19 +103,13 @@ Default URLs:
## Git Workflow
> ⚠️ **NEVER commit directly to `main`.** Always use feature branches.
>
> **PR base:** target the active `release/vX.Y.Z` branch (not `main`). See
> [`docs/ops/BRANCHING_MODEL.md`](docs/ops/BRANCHING_MODEL.md) for the
> release-per-branch + tag-at-ship model.
```bash
# Branch from the active release tip (example: release/v3.8.49)
git fetch origin
git checkout -b feat/your-feature-name origin/release/v3.8.49
git checkout -b feat/your-feature-name
# ... make changes ...
git commit -m "feat: describe your change"
git push -u origin feat/your-feature-name
# Open a Pull Request with base = release/v3.8.49
# Open a Pull Request on GitHub
```
### Branch Naming
@@ -198,14 +192,11 @@ Coverage notes:
### Pull Request Requirements
Before opening a PR, run the focused loop for what you changed. The full unit suite
(4 CI shards), Vitest, the **60%+** coverage gate, and the production build are CI's
responsibility — running them locally adds no signal the PR checks will not already
give you, and on smaller machines it can saturate the host (#8084):
Before opening or merging a PR:
- Run the test files that cover your change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
- Run `npm run lint`
- Include or update automated tests in the same PR whenever production code changes
- Run `npm run test:unit`
- Run `npm run test:coverage`
- Ensure the coverage gate stays at **60%+** statements/lines/functions/branches
- Include the changed or added test files in the PR description when production code changed
- Check the SonarQube result on the PR when the project secrets are configured in CI
@@ -231,31 +222,6 @@ Current test status: **122 unit test files** covering:
- **Zod validation** — Use Zod v4 schemas for all API input validation
- **Naming**: Files = camelCase/kebab-case, components = PascalCase, constants = UPPER_SNAKE
### Error handling / empty catch blocks
Never leave a `catch` unexplained. Classify it into one of two buckets (operationalizes
the hard rule "never silently swallow errors in SSE streams"):
- **Intentional (our own best-effort cleanup/telemetry)** — a failure here is expected and
harmless; add a one-line rationale comment, no logging (logging on every request is the
noise this convention avoids).
```ts
} catch {} // closing an already-closed controller after client disconnect is expected
```
- **Should log (external/caller-supplied code, or the swallow changes control flow)** — keep
the catch (never let it break the stream) but emit a contextual `console.debug`/`warn` so the
failure is discoverable.
```ts
} catch (e) {
console.debug("[STREAM] onFailure callback error:", e);
}
```
See `open-sse/utils/stream.ts` and `open-sse/utils/streamHandler.ts` for applied examples.
---
## Project Structure

View File

@@ -62,28 +62,10 @@ RUN test -f package-lock.json \
# from that indirection. Invoking `node-gyp rebuild` directly inside the package
# directory bypasses npm's script-running layer entirely and is deterministic
# regardless of npm version or ignore-scripts allowlist behavior.
# node-gyp comes from npm's own bundled copy (deterministic, already in the image)
# instead of `npx --yes`, which would install an arbitrary registry version
# on-demand and run its lifecycle scripts (Sonar docker:S6505).
#
# 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=npm-cache,target=/root/.npm \
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
&& (cd node_modules/better-sqlite3 \
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
&& node -e "require('better-sqlite3')(':memory:').close()" \
&& 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))
&& (cd node_modules/better-sqlite3 && npx --yes node-gyp rebuild) \
&& node -e "require('better-sqlite3')(':memory:').close()"
# Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era
# TurbopackInternalError panic ("entered unreachable code: there must be a path to a
@@ -128,12 +110,6 @@ LABEL org.opencontainers.image.title="omniroute" \
ENV NODE_ENV=production
ENV PORT=20128
ENV HOSTNAME=0.0.0.0
# Runtime heap ceiling. 1024MB is enough for normal traffic but can be tight
# for large fusion-combo panels (many models fanned out in parallel, each
# response buffered in full — see open-sse/services/fusion.ts::FUSION_DEFAULTS
# .maxPanel, issue #1905). Override at `docker run` time with
# `-e OMNIROUTE_MEMORY_MB=2048` (or higher) if you raise fusionTuning.maxPanel
# above the default cap.
ENV OMNIROUTE_MEMORY_MB=1024
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"

View File

@@ -27,7 +27,7 @@ When creating _any_ validation tests or one-off logic scripts, default to using
7. **Never bypass Husky hooks** (`--no-verify`, `--no-gpg-sign`) without explicit operator approval.
8. **Always validate inputs with Zod schemas** from `src/shared/validation/schemas.ts`.
9. **Always include tests when changing production code** (`src/`, `open-sse/`, `electron/`, `bin/`).
10. **Coverage must stay**60 % statements / lines / functions / branches — the official CI gate (`npm run test:coverage`). The ratchet baseline in `quality-baseline.json` may freeze a higher floor; never regress it.
10. **Coverage must stay**75 % statements / 75 % lines / 75 % functions / 70 % branches (real measured: ~82 %).
## 3. Codebase navigation

1326
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -42,7 +42,7 @@ Request → CORS → Authz pipeline (classify → policies → enforce)
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
| **API Key Auth** | HMAC-signed keys with CRC validation |
| **OAuth 2.0 + PKCE** | 13 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Kiro, Qoder, Windsurf, GitLab Duo) |
| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) |
| **Token Refresh** | Automatic OAuth token refresh before expiry |
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
@@ -77,28 +77,21 @@ Custom guardrails register via `registerGuardrail(new MyGuardrail())`. The model
### 🧠 Prompt Injection Guard
Best-effort heuristic middleware that detects prompt injection patterns in LLM requests.
**Not a complete prompt-injection firewall** — can produce false positives (benign
persona/RPG prompts) and false negatives (leetspeak, spacing, non-English patterns).
Middleware that detects and blocks prompt injection attacks in LLM requests:
| Pattern Type | Severity | Example |
| ------------------- | -------- | ---------------------------------------------- |
| System Override | High | "ignore all previous instructions" |
| Role Hijack | Medium | "you are now DAN, you can do anything" |
| Delimiter Injection | High | Encoded separators to break context boundaries |
| DAN/Jailbreak | Medium | Known jailbreak prompt patterns |
| Instruction Leak | High | "show me your system prompt" |
| Encoding Evasion | Medium | base64/rot13/hex decode + instruction keywords |
Only **High** severity detections are blocked in `block` mode. Medium-severity
families are logged but never blocked by `sanitizeRequest`.
| Role Hijack | High | "you are now DAN, you can do anything" |
| Delimiter Injection | Medium | Encoded separators to break context boundaries |
| DAN/Jailbreak | High | Known jailbreak prompt patterns |
| Instruction Leak | Medium | "show me your system prompt" |
Configure via dashboard (Settings → Security) or `.env`:
```env
INPUT_SANITIZER_ENABLED=true
INPUT_SANITIZER_MODE=block # warn | block (injection policy; legacy "redact" does not strip injection text)
INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
INPUT_SANITIZER_MODE=block # warn | block | redact
```
### 🔒 PII Redaction
@@ -115,8 +108,7 @@ Automatic detection and optional redaction of personally identifiable informatio
| SSN (US) | `123-45-6789` | `[SSN_REDACTED]` |
```env
PII_REDACTION_ENABLED=true # request PII rewrite; independent of INPUT_SANITIZER_MODE
PII_RESPONSE_SANITIZATION=true # optional: redact PII in provider responses returned to clients
PII_REDACTION_ENABLED=true
```
### 🌐 Network Security

View File

@@ -1,230 +0,0 @@
/**
* ESM path-alias resolver for global installs.
*
* Problem (#7791): when OmniRoute is installed via `npm i -g omniroute`, the
* package files live under `node_modules/omniroute/`. tsx's tsconfig-path
* resolution does not apply there, so specifiers like `@/shared/utils/featureFlags`
* (declared in tsconfig.json `paths` as `@/* → ./src/*`) or
* `@omniroute/open-sse/services/usage` fail with `ERR_MODULE_NOT_FOUND`.
* The CLI crashes before any command can run.
*
* Fix: register a Node ESM `resolve` hook that rewrites alias specifiers to
* absolute file URLs. Covers all tsconfig.json `paths` entries:
* - `@/*` → `./src/*`
* - `@omniroute/open-sse` → `./open-sse/index.ts`
* - `@omniroute/open-sse/*` → `./open-sse/*`
* The hook runs after tsx so `.ts` extensions are already handled, and only
* intercepts matched prefixes — everything else falls through to Node's
* default resolver.
*
* Exposed as pure functions so the mapping logic is unit-testable without a
* running module loader.
*/
import { existsSync, statSync } from "node:fs";
import { dirname, join, relative, isAbsolute } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
/**
* Alias mapping table — mirrors tsconfig.json `paths`.
* Processed top-to-bottom; first matching prefix wins.
*
* Each entry:
* prefix — specifier prefix to match (e.g. `"@/"`, `"@omniroute/open-sse/"`)
* target — directory name under the package root (e.g. `"src"`, `"open-sse"`)
* exact — if true, the prefix also matches when the specifier equals the
* prefix *without* a trailing slash (e.g. `@omniroute/open-sse` →
* `<root>/open-sse/index.ts`).
*
* Exported for tests/consumers.
*/
export const ALIAS_MAP = [
{ prefix: "@/", target: "src", exact: false },
{ prefix: "@omniroute/open-sse/", target: "open-sse", exact: false },
{ prefix: "@omniroute/open-sse", target: "open-sse", exact: true },
];
/** @deprecated Use ALIAS_MAP instead. Kept for backward compat. */
export const ALIAS_PREFIX = "@/";
// This file is ESM (no CJS __dirname global) — derive it from import.meta.url
// so the pathToFileURL(join(__dirname, ...)) call below resolves correctly
// regardless of the caller's cwd.
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Resolve an alias specifier to an absolute file URL.
*
* Rules mirror tsconfig.json `paths` via `ALIAS_MAP`:
* "@/..." → <root>/src/...
* "@omniroute/open-sse/..." → <root>/open-sse/...
* "@omniroute/open-sse" → <root>/open-sse/index.*
*
* - Strips the matched alias prefix and joins the remainder against the
* corresponding target directory.
* - Probes the underlying filesystem for the actual source file: the specifier
* itself, then with common source extensions (`.ts`, `.tsx`, `.js`, `.mjs`,
* `.cjs`, `.json`), then `<dir>/index.*`. Returns the first existing match
* as a `file://` URL.
* - Returns `null` for specifiers that do not match any alias, for malformed
* escapes, for path-traversal attempts, or when no corresponding source
* file exists on disk. The caller treats `null` as "defer to the default
* resolver".
*
* @param {string} specifier Module specifier from an `import` statement.
* @param {string} root Absolute path to the package root.
* @returns {string|null} Absolute `file://` URL, or `null` when unresolved.
*/
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"];
export function resolveAlias(specifier, root) {
if (typeof specifier !== "string" || !root || typeof root !== "string") {
return null;
}
// Find the first matching alias entry (top-to-bottom order).
let matchedEntry = null;
let rest = null;
for (const entry of ALIAS_MAP) {
if (specifier.startsWith(entry.prefix)) {
// For non-exact entries, require at least one char after the prefix
// to avoid matching bare "@/" as "nothing".
const after = specifier.slice(entry.prefix.length);
if (after.length === 0 && !entry.exact) continue;
matchedEntry = entry;
rest = after;
break;
}
}
if (!matchedEntry) return null;
const targetDir = join(root, matchedEntry.target);
// Exact match (e.g. `@omniroute/open-sse` with no trailing path) →
// resolve to `<target>/index.*`.
if (rest === "" || rest === undefined) {
return probeIndex(targetDir);
}
// Guard against absolute-ish escapes (`@//etc/passwd`, `@/\x00`).
if (rest.startsWith("/") || rest.startsWith("\\")) {
return null;
}
// Guard against path-traversal escapes (`@/../../../etc/hostname`).
const segments = rest.split(/[\\\/]+/);
if (segments.includes("..")) {
return null;
}
const base = join(targetDir, rest);
if (!isWithinRoot(targetDir, base)) {
return null;
}
return probeFile(base) ?? probeIndex(base) ?? null;
}
/**
* Probe a bare path and its extension variants. Returns the first existing
* match as a `file://` URL, or `null`.
*/
function probeFile(base) {
// Try extension variants first — a bare `base` that happens to be a directory
// would match existsSync() but should NOT be returned as a file URL (the
// caller expects a file, not a directory). Extension-probing avoids this
// false positive (e.g. `usage` vs `usage.ts` vs `usage/`).
for (const ext of SOURCE_EXTENSIONS) {
const candidate = base + ext;
if (existsSync(candidate)) return pathToFileURL(candidate).href;
}
// Only accept the bare path if it is NOT a directory.
if (existsSync(base)) {
try {
const st = statSync(base);
if (!st.isDirectory()) return pathToFileURL(base).href;
} catch {}
}
return null;
}
/**
* Probe a directory for an `index.*` entry. Returns the first existing
* match as a `file://` URL, or `null`.
*/
function probeIndex(dir) {
const indexBase = join(dir, "index");
for (const ext of SOURCE_EXTENSIONS) {
const candidate = indexBase + ext;
if (existsSync(candidate)) return pathToFileURL(candidate).href;
}
return null;
}
/**
* True when `candidate` resolves to a location inside `ancestor` (or is
* `ancestor` itself). Used as a second, path-normalization-aware layer of
* defense against traversal beyond the literal `..` segment check above.
*
* @param {string} ancestor
* @param {string} candidate
* @returns {boolean}
*/
function isWithinRoot(ancestor, candidate) {
const rel = relative(ancestor, candidate);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
/**
* Register the ESM resolve hook for the current process. Safe to call multiple
* times — subsequent calls are no-ops once the hook is installed.
*
* 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` on environments where `module.register` is unavailable.
*/
let _registered = false;
export async function registerAliasResolver(root) {
// Validate input FIRST, before the _registered short-circuit. Otherwise the
// second call in the same process (e.g. a test suite that already registered
// once) would silently return `true` for invalid input instead of rejecting,
// masking programmer errors. Input validation must be unconditional.
if (!root || typeof root !== "string") {
throw new TypeError("registerAliasResolver: root must be a non-empty string");
}
if (_registered) return true;
// if the directory does not exist we would only mask a real misconfiguration
// by installing a hook that rewrites to nowhere.
if (!existsSync(join(root, "src"))) {
return false;
}
try {
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
// `new URL(...)` call; a file URL produced by pathToFileURL() is a trusted,
// fully-parsed URL — no sanitization ambiguity. The hook source lives in
// `bin/aliasResolverHook.mjs` (sibling of this file), shipped via
// package.json "files": ["bin/"].
const hookPath = join(__dirname, "aliasResolverHook.mjs");
const hookUrl = pathToFileURL(hookPath);
register(hookUrl, { data: { root } });
_registered = true;
return true;
} catch {
// 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;
}
}
// #7808: the ESM loader hook source now lives in `bin/aliasResolverHook.mjs`,
// loaded via `pathToFileURL()` above. The previous inline `HOOK_SOURCE` template
// literal was removed because its `new URL(\`data:text/javascript,...\`)` wrapper
// triggered CodeQL `js/incomplete-url-substring-sanitization`. The hook logic
// itself is unchanged — see aliasResolverHook.mjs for the resolver behaviour.

View File

@@ -1,126 +0,0 @@
/**
* ESM loader hook for path-alias resolution (#7791 + #7808).
*
* This file runs in Node's loader worker thread after being registered via
* `module.register(url, data)` from `bin/aliasResolver.mjs`. It MUST NOT import
* anything from the parent module — all inputs arrive through `initialize(data)`.
*
* Behaviour:
* - Rewrites alias specifiers to absolute filesystem paths, mirroring
* tsconfig.json `paths`:
* - `@/*` → <root>/src/*
* - `@omniroute/open-sse` → <root>/open-sse/index.*
* - `@omniroute/open-sse/*` → <root>/open-sse/*
* - Probes the usual source extensions (`.ts`, `.tsx`, `.js`, `.mjs`, `.cjs`,
* `.json`) plus `index.*` for directory imports.
* - Returns `shortCircuit: true` only when a candidate file exists on disk;
* otherwise delegates to the next resolver (tsx/Node) so unrelated imports
* and legitimate "module not found" errors pass through unchanged.
*
* Why a separate file instead of an inline `data:` URL?
* CodeQL's `js/incomplete-url-substring-sanitization` flags dynamic `new URL(...)`
* construction with interpolated strings. A real file URL produced by
* `pathToFileURL()` is a trusted, fully-parsed URL — no sanitization ambiguity.
*/
import { pathToFileURL } from "node:url";
import { join, relative, isAbsolute } from "node:path";
import { existsSync, statSync } from "node:fs";
let ROOT = "";
export function initialize(data) {
ROOT = (data && data.root) || "";
}
const EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"];
/**
* Alias prefix table — mirrors ALIAS_MAP in aliasResolver.mjs and
* tsconfig.json `paths`. Processed top-to-bottom; first match wins.
*
* @type {Array<{prefix: string, target: string, exact: boolean}>}
*/
const ALIAS_TABLE = [
{ prefix: "@/", target: "src", exact: false },
{ prefix: "@omniroute/open-sse/", target: "open-sse", exact: false },
{ prefix: "@omniroute/open-sse", target: "open-sse", exact: true },
];
function tryResolveAliasFsPath(specifier) {
if (!ROOT || typeof specifier !== "string") return null;
// Find the first matching alias entry.
let matchedEntry = null;
let rest = null;
for (const entry of ALIAS_TABLE) {
if (specifier.startsWith(entry.prefix)) {
const after = specifier.slice(entry.prefix.length);
if (after.length === 0 && !entry.exact) continue;
matchedEntry = entry;
rest = after;
break;
}
}
if (!matchedEntry) return null;
const targetDir = join(ROOT, matchedEntry.target);
// Exact match (e.g. `@omniroute/open-sse`) → resolve to `<target>/index.*`.
if (rest === "" || rest === undefined) {
return probeIndex(targetDir);
}
// Guard against absolute-ish escapes.
if (rest.startsWith("/") || rest.startsWith("\\")) return null;
// Guard against path-traversal escapes.
const segments = rest.split(/[\\\/]+/);
if (segments.includes("..")) return null;
const base = join(targetDir, rest);
if (!isWithinRoot(targetDir, base)) return null;
return probeFile(base) ?? probeIndex(base) ?? null;
}
function probeFile(base) {
// Extension variants first — avoids matching a bare directory name.
for (const ext of EXTENSIONS) {
const candidate = base + ext;
if (existsSync(candidate)) return candidate;
}
if (existsSync(base)) {
try {
const st = statSync(base);
if (!st.isDirectory()) return base;
} catch {}
}
return null;
}
function probeIndex(dir) {
const indexBase = join(dir, "index");
for (const ext of EXTENSIONS) {
const candidate = indexBase + ext;
if (existsSync(candidate)) return candidate;
}
return null;
}
/**
* True when `candidate` resolves to a location inside `ancestor` (or is
* `ancestor` itself). Path-normalization-aware defense against traversal.
*/
function isWithinRoot(ancestor, candidate) {
const rel = relative(ancestor, candidate);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
export function resolve(specifier, context, nextResolve) {
const fsPath = tryResolveAliasFsPath(specifier);
if (fsPath) {
return {
url: pathToFileURL(fsPath).href,
shortCircuit: true,
};
}
return nextResolve(specifier, context);
}

View File

@@ -1,163 +0,0 @@
import { chmodSync, existsSync, writeFileSync } from "node:fs";
import { decryptCredential } from "../encryption.mjs";
import { findProviderConnection, listProviderConnections } from "../provider-store.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { t } from "../i18n.mjs";
/**
* Local-only, operator-invoked command that dumps DECRYPTED provider credentials
* (apiKey/accessToken/refreshToken/idToken). This never runs inside the HTTP server
* process and must never be reachable over the network — no src/app/api/ route wraps
* this. See docs/security/ for the threat-model writeup referenced in issue #6683.
*/
const CREDENTIAL_FIELDS = [
{ key: "apiKey", envSuffix: "API_KEY" },
{ key: "accessToken", envSuffix: "ACCESS_TOKEN" },
{ key: "refreshToken", envSuffix: "REFRESH_TOKEN" },
{ key: "idToken", envSuffix: "ID_TOKEN" },
];
const VALID_FORMATS = new Set(["json", "env"]);
const SECURE_FILE_MODE = 0o600;
export function registerAuthExport(program) {
program
.command("auth export")
.description(t("authExport.description"))
.option("--id <id>", t("authExport.idOpt"))
.option("--format <format>", t("authExport.formatOpt"), "json")
.option("--out <file>", t("authExport.outOpt"))
.option("--force", t("authExport.forceOpt"))
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const exitCode = await runAuthExportCommand({ ...opts, ...globalOpts });
if (exitCode !== 0) process.exit(exitCode);
});
}
export async function runAuthExportCommand(opts = {}) {
// Security control (a): confirmation gate BEFORE any DB access — a dry invocation
// never opens the database and never decrypts anything.
if (!opts.force) {
printConfirmationGate();
return 0;
}
const format = opts.format || "json";
if (!VALID_FORMATS.has(format)) {
console.error(t("authExport.invalidFormat", { format }));
return 1;
}
if (!process.env.STORAGE_ENCRYPTION_KEY) {
console.error(t("authExport.missingKey"));
return 1;
}
// Security control (b): stderr warning banner BEFORE any plaintext is emitted.
process.stderr.write(t("authExport.warning") + "\n");
const rows = await loadTargetConnections(opts.id);
if (rows === null) {
console.error(t("authExport.notFound", { id: opts.id }));
return 1;
}
const exported = rows.map(exportConnection);
const content = format === "env" ? formatAsEnv(exported) : formatAsJson(exported);
if (opts.out) {
writeSecureFile(opts.out, content);
} else {
console.log(content);
}
return 0;
}
function printConfirmationGate() {
console.log(
`\n${t("authExport.confirmHeading")}\n\n${t("authExport.confirmBody")}\n\n${t("authExport.confirmFooter")}\n`
);
}
async function loadTargetConnections(id) {
const { db } = await openOmniRouteDb();
try {
if (!id) return listProviderConnections(db);
const connection = findProviderConnection(db, id);
return connection ? [connection] : null;
} finally {
db.close();
}
}
function decryptField(rawValue) {
// Security control (d): a per-field decrypt failure surfaces as a boolean flag,
// never the caught error text. Security control (e): the caught error is never
// interpolated into any message.
try {
return { value: decryptCredential(rawValue), failed: false };
} catch {
return { value: null, failed: true };
}
}
function exportConnection(connection) {
const result = {
id: connection.id,
provider: connection.provider,
name: connection.name,
authType: connection.authType,
};
for (const { key } of CREDENTIAL_FIELDS) {
const rawValue = connection[key];
if (!rawValue) {
result[key] = null;
result[`${key}DecryptFailed`] = false;
continue;
}
const { value, failed } = decryptField(rawValue);
result[key] = value;
result[`${key}DecryptFailed`] = failed;
}
return result;
}
function formatAsJson(rows) {
return JSON.stringify(rows, null, 2);
}
function envSafeSegment(value) {
return String(value || "")
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
function formatAsEnv(rows) {
const lines = [];
for (const row of rows) {
lines.push(`# ${row.provider} (${row.id})`);
const providerSegment = envSafeSegment(row.provider);
for (const { key, envSuffix } of CREDENTIAL_FIELDS) {
const value = row[key];
if (!value) continue;
lines.push(`OMNIROUTE_${providerSegment}_${envSuffix}=${value}`);
}
}
return lines.join("\n");
}
function writeSecureFile(filePath, content) {
// Security control (c): file output written with mode 0o600 (plus chmodSync if the
// file pre-existed, belt-and-suspenders against an already world-readable file).
const preExisted = existsSync(filePath);
writeFileSync(filePath, content, { mode: SECURE_FILE_MODE });
if (preExisted) {
chmodSync(filePath, SECURE_FILE_MODE);
}
}

View File

@@ -78,15 +78,18 @@ export function registerBackup(program) {
if (exitCode !== 0) process.exit(exitCode);
});
// Legacy: `omniroute backup` without a subcommand still creates a backup
// (documented as the canonical usage in USER_GUIDE.md / CLI-TOOLS.md /
// AGENT-SKILLS.md). No flags are declared here — declaring the same
// option names as `create`/`auto enable` here previously shadowed them
// (#8512), and no doc shows `omniroute backup` invoked with flags.
// Legacy: `omniroute backup` without subcommand still creates a backup
backup.action(async (opts) => {
const exitCode = await runBackupCommand(opts);
if (exitCode !== 0) process.exit(exitCode);
});
backup
.option("--name <name>", t("backup.nameOpt"))
.option("--cloud", t("backup.cloudOpt"))
.option("--encrypt", t("backup.encryptOpt"))
.option("--key-file <path>", t("backup.keyFileOpt"))
.option("--exclude <pattern>", t("backup.excludeOpt"), (v, prev = []) => [...prev, v], [])
.option("--retention <n>", t("backup.retentionOpt"), parseInt);
}
export function registerRestore(program) {

View File

@@ -1,22 +1,17 @@
import { execFile } from "node:child_process";
import { t } from "../i18n.mjs";
function parsePort(value, fallback) {
const parsed = parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
export function registerDashboard(program) {
program
.command("dashboard")
.description(t("dashboard.description"))
.option("--url", t("dashboard.urlOnly"))
.option("--port <port>", "Port the server is running on")
.option("--port <port>", "Port the server is running on", "20128")
.option("--tui", t("dashboard.tui") || "Open interactive TUI dashboard (terminal UI)")
.action(async (opts, cmd) => {
if (opts.tui) {
const globalOpts = cmd.optsWithGlobals();
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128);
const port = opts.port ? parseInt(String(opts.port), 10) : 20128;
const baseUrl = globalOpts.baseUrl ?? `http://localhost:${port}`;
const apiKey = globalOpts.apiKey ?? null;
const { startInteractiveTui } = await import("../tui/Dashboard.jsx");
@@ -29,7 +24,7 @@ export function registerDashboard(program) {
}
export async function runDashboardCommand(opts = {}) {
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128);
const port = opts.port ? parseInt(String(opts.port), 10) : 20128;
const dashboardUrl = `http://localhost:${port}`;
if (opts.url) {
@@ -49,22 +44,22 @@ export async function runDashboardCommand(opts = {}) {
return 0;
}
/**
* Resolve the command and args to open a URL in the default browser
* for a given platform. Exported for testing — callers should use openFallback().
* @param {"darwin"|"win32"|string} platform
* @param {string} url
* @returns {{ cmd: string, args: string[] }}
*/
export function resolveOpenCommand(platform, url) {
if (platform === "darwin") return { cmd: "open", args: [url] };
if (platform === "win32") return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
return { cmd: "xdg-open", args: [url] };
}
function openFallback(url) {
return new Promise((resolve) => {
const { cmd, args } = resolveOpenCommand(process.platform, url);
const { platform } = process;
let cmd, args;
if (platform === "darwin") {
cmd = "open";
args = [url];
} else if (platform === "win32") {
cmd = "cmd";
args = ["/c", "start", "", url];
} else {
cmd = "xdg-open";
args = [url];
}
execFile(cmd, args, { stdio: "ignore" }, () => resolve());
});
}

View File

@@ -106,10 +106,10 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
if (!res.ok) throw new Error(`status ${res.status}`);
} catch {
console.error(
(
t("launch.notRunning") ||
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
).replace("{port}", baseUrl)
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
"{port}",
baseUrl
)
);
return 1;
}
@@ -120,14 +120,7 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
return await new Promise((resolve) => {
// #8246: on Windows, npm installs claude as a .cmd shim — spawn() without
// shell:true cannot resolve PATHEXT shims and fails with ENOENT.
const claudeCommand = process.platform === "win32" ? "claude.cmd" : "claude";
const child = spawn(claudeCommand, claudeArgs, {
env,
stdio: "inherit",
...(process.platform === "win32" ? { shell: true, windowsHide: true } : {}),
});
const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
child.on("error", (err) => {
if (err && err.code === "ENOENT") {
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
@@ -149,10 +142,7 @@ export function registerLaunch(program) {
)
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
.option(
"--profile <name>",
"Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)"
)
.option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
.allowUnknownOption(true)

View File

@@ -7,6 +7,7 @@ const PROVIDERS_WITH_OAUTH = [
{ id: "gemini", name: "Google Gemini", flow: "browser" },
{ id: "antigravity", name: "Antigravity", flow: "browser" },
{ id: "windsurf", name: "Windsurf", flow: "browser" },
{ id: "qwen", name: "Qwen Code", flow: "browser" },
{ id: "cursor", name: "Cursor", flow: "import" },
{ id: "zed", name: "Zed", flow: "import" },
{ id: "kiro", name: "Amazon Kiro", flow: "social" },

View File

@@ -37,7 +37,6 @@ import { registerProviders } from "./providers.mjs";
import { registerProvider } from "./provider-cmd.mjs";
import { registerConfig } from "./config.mjs";
import { registerKeys } from "./keys.mjs";
import { registerAuthExport } from "./auth-export.mjs";
import { registerModels } from "./models.mjs";
import { registerCombo } from "./combo.mjs";
import { registerStatus } from "./status.mjs";
@@ -70,8 +69,8 @@ import { registerSetupCursor } from "./setup-cursor.mjs";
import { registerSetupRoo } from "./setup-roo.mjs";
import { registerSetupCrush } from "./setup-crush.mjs";
import { registerSetupGoose } from "./setup-goose.mjs";
import { registerSetupAider } from "./setup-aider.mjs";
import { registerSetupQwen } from "./setup-qwen.mjs";
import { registerSetupAider } from "./setup-aider.mjs";
import { registerConnect } from "./connect.mjs";
import { registerContexts } from "./contexts.mjs";
import { registerTokens } from "./tokens.mjs";
@@ -119,7 +118,6 @@ export function registerCommands(program) {
registerProvider(program);
registerConfig(program);
registerKeys(program);
registerAuthExport(program);
registerModels(program);
registerCombo(program);
registerStatus(program);
@@ -153,8 +151,8 @@ export function registerCommands(program) {
registerSetupRoo(program);
registerSetupCrush(program);
registerSetupGoose(program);
registerSetupAider(program);
registerSetupQwen(program);
registerSetupAider(program);
registerConnect(program);
registerContexts(program);
registerTokens(program);

View File

@@ -134,7 +134,7 @@ export async function runServe(opts = {}) {
"Release",
"better_sqlite3.node"
);
if (!process.versions.bun && existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) {
if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) {
console.error(
"\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m"
);
@@ -230,10 +230,7 @@ export async function runServe(opts = {}) {
function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
const server = spawn(process.versions.bun ? process.execPath : "node", [
...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)),
serverJs,
], {
const server = spawn("node", [...buildNodeHeapArgs(process.env, memoryLimit), serverJs], {
cwd: APP_DIR,
env,
stdio: "ignore",
@@ -249,10 +246,7 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen, startedAt) {
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
const server = spawn(process.versions.bun ? process.execPath : "node", [
...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)),
serverJs,
], {
const server = spawn("node", [...buildNodeHeapArgs(process.env, memoryLimit), serverJs], {
cwd: APP_DIR,
env,
stdio: "pipe",

View File

@@ -218,29 +218,13 @@ function registerPluginInOpenCodeConfig({
* a clear "could not run opencode" message instead of a hard import
* failure.
*/
/**
* Pure resolver for the `opencode auth login` spawn descriptor. Extracted so the
* platform-branching logic is unit-testable without mocking child_process or
* mutating process.platform.
*
* On Windows the `opencode` binary is an npm `.cmd` shim that Node's hardened
* spawnSync (post CVE-2024-27980) refuses to run without a shell — spawning it
* with shell:false throws EINVAL (#7913). Mirror the same fix already applied to
* codex (resolveCodexSpawn in launch-codex.mjs, crediting #6263) and
* qodercli/Auggie (#6263/#6304): shell:true on win32, shell:false everywhere else.
*/
export function resolveOpenCodeAuthSpawn(providerId, platform = process.platform) {
const isWin = platform === "win32";
return {
command: isWin ? "opencode.cmd" : "opencode",
args: ["auth", "login", "--provider", providerId],
options: { stdio: "inherit", shell: isWin },
};
}
export function runOpenCodeAuth(providerId) {
const { command, args, options } = resolveOpenCodeAuthSpawn(providerId);
const res = spawnSync(command, args, options);
function runOpenCodeAuth(providerId) {
const isWin = process.platform === "win32";
const opencodeBin = isWin ? "opencode.cmd" : "opencode";
const res = spawnSync(opencodeBin, ["auth", "login", "--provider", providerId], {
stdio: "inherit",
shell: false,
});
if (res.error) {
// ENOENT = opencode is not on PATH
if (res.error.code === "ENOENT") {

View File

@@ -1,166 +1,156 @@
/** Configure Qwen Code's OpenAI-compatible provider for OmniRoute. */
/**
* omniroute setup-qwen — configure Qwen Code (QwenLM/qwen-code) for OmniRoute.
*
* Qwen Code is a terminal AI agent with a file-based config at
* ~/.qwen/settings.json. For a custom OpenAI-compatible endpoint it uses a
* `modelProviders` entry with authType "openai", baseUrl WITH /v1, and an
* `envKey` naming the env var holding the key (secret stays in the env, never the
* file). Remote-aware; headless test via `qwen -p "..."`.
*/
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import path from "node:path";
import {
mergeQwenCodeEnv,
mergeQwenCodeSettings,
normalizeQwenCodeBaseUrl,
} from "../../../src/shared/services/qwenCodeConfig.ts";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { createPrompt, printError, printHeading, printInfo, printSuccess } from "../io.mjs";
/** Resolve base URL and key from flags, active context, then local defaults. */
export function resolveQwenTarget(opts = {}) {
let root = opts.remote ? String(opts.remote) : "";
let context;
if (!root || !(opts.apiKey ?? opts["api-key"])) {
try {
context = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
} catch {
// An active context is optional for local setup.
}
}
if (!root) root = context?.baseUrl || "";
if (!root) {
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
root = `http://localhost:${port}`;
}
const apiKey =
opts.apiKey ??
opts["api-key"] ??
context?.accessToken ??
context?.apiKey ??
process.env.OMNIROUTE_API_KEY ??
"sk_omniroute";
return { baseUrl: normalizeQwenCodeBaseUrl(root), apiKey };
function ensureV1(url) {
const s = String(url || "").replace(/\/+$/, "");
return s.endsWith("/v1") ? s : `${s}/v1`;
}
const readSettings = (filePath) => {
if (!existsSync(filePath)) return {};
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Qwen Code settings.json must contain a JSON object");
}
return parsed;
};
const readText = (filePath) => (existsSync(filePath) ? readFileSync(filePath, "utf8") : "");
const writeAtomic = (filePath, content, mode) => {
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
writeFileSync(tempPath, content, { encoding: "utf8", mode });
if (mode !== undefined) chmodSync(tempPath, mode);
renameSync(tempPath, filePath);
} catch (error) {
/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */
export function resolveQwenTarget(opts = {}) {
let root;
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
else {
try {
unlinkSync(tempPath);
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
} catch {
// The temporary file may not have been created.
/* none */
}
throw error;
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
}
};
let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) {
try {
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
apiKey = c?.accessToken || c?.apiKey;
} catch {
/* none */
}
}
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
return { baseUrl: ensureV1(root), apiKey };
}
const fetchModelIds = async (baseUrl, apiKey) => {
/** Merge the OmniRoute modelProvider into Qwen's settings.json (preserve rest). */
export function buildQwenSettings(existing, { baseUrl, model }) {
const s = existing && typeof existing === "object" ? { ...existing } : {};
const providers = Array.isArray(s.modelProviders)
? s.modelProviders.filter((p) => p?.id !== "omniroute")
: [];
providers.push({
id: "omniroute",
name: "OmniRoute",
authType: "openai",
baseUrl,
envKey: "OMNIROUTE_API_KEY",
});
s.modelProviders = providers;
if (model) {
s.selectedProvider = "omniroute";
s.model = model;
}
return s;
}
function readJson(path) {
try {
const response = await fetch(`${baseUrl}/models`, {
headers: { Authorization: `Bearer ${apiKey}` },
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
} catch {
/* corrupt/missing */
}
return {};
}
async function fetchModelIds(baseUrl, apiKey) {
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/v1/models`, {
headers,
signal: AbortSignal.timeout(8000),
});
if (!response.ok) return [];
const body = await response.json();
const models = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return models.map((entry) => (typeof entry === "string" ? entry : entry?.id)).filter(Boolean);
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 [];
}
};
}
export async function runSetupQwenCommand(opts = {}) {
const { baseUrl, apiKey } = resolveQwenTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const settingsPath =
opts.configPath ?? opts["config-path"] ?? path.join(os.homedir(), ".qwen", "settings.json");
const envPath = opts.envPath ?? opts["env-path"] ?? path.join(path.dirname(settingsPath), ".env");
const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".qwen", "settings.json");
printHeading("OmniRoute → Qwen Code (OpenAI-compatible)");
printHeading("OmniRoute → Qwen Code (openai-compatible)");
printInfo(`baseUrl: ${baseUrl}`);
let model = String(opts.model || "").trim();
if (!model && !opts.yes) {
const modelIds = await fetchModelIds(baseUrl, apiKey);
if (modelIds.length > 0) {
printInfo(`Examples: ${modelIds.slice(0, 20).join(", ")}${modelIds.length > 20 ? " …" : ""}`);
}
const prompt = createPrompt();
try {
model = String(await prompt.ask("Model id for Qwen Code")).trim();
} finally {
prompt.close();
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 ? " …" : ""}`);
const prompt = createPrompt();
try {
model = await prompt.ask("Model id for Qwen");
} finally {
prompt.close();
}
}
}
if (!model) {
printError("A model is required. Pass --model <id>.");
return 2;
}
try {
const settings = mergeQwenCodeSettings(readSettings(settingsPath), { baseUrl, model });
const envText = mergeQwenCodeEnv(readText(envPath), apiKey);
const settingsText = `${JSON.stringify(settings, null, 2)}\n`;
const merged = buildQwenSettings(readJson(configPath), { baseUrl, model });
const out = JSON.stringify(merged, null, 2) + "\n";
if (dryRun) {
console.log(`\n${settingsText}`);
printInfo(`[dry-run] settings → ${settingsPath}`);
printInfo(`[dry-run] credential → ${envPath} (OMNIROUTE_API_KEY)`);
return 0;
}
mkdirSync(path.dirname(settingsPath), { recursive: true, mode: 0o700 });
mkdirSync(path.dirname(envPath), { recursive: true, mode: 0o700 });
writeAtomic(settingsPath, settingsText);
writeAtomic(envPath, envText, 0o600);
printSuccess(`Wrote ${settingsPath}`);
printSuccess(`Updated ${envPath} (OMNIROUTE_API_KEY only)`);
printInfo('Run: qwen (or headless: qwen -p "reply OK")');
return 0;
} catch (error) {
printError(`Failed to configure Qwen Code: ${error?.message || error}`);
return 1;
if (dryRun) {
console.log("\n" + out);
printInfo(`[dry-run] ${configPath}`);
} else {
mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, out, "utf8");
printSuccess(`Wrote ${configPath}`);
}
printInfo(
"\nProvide the key (settings reference OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."
);
printInfo('Then run: qwen (or headless: qwen -p "reply OK")');
return 0;
}
export function registerSetupQwen(program) {
program
.command("setup-qwen")
.description("Configure Qwen Code's upstream V4 modelProviders format for OmniRoute")
.description(
"Configure Qwen Code for OmniRoute: write ~/.qwen/settings.json (openai modelProvider)"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL")
.option("--api-key <key>", "OmniRoute API key")
.option("--model <id>", "Model id for Qwen Code")
.option("--config-path <path>", "Qwen Code settings.json path")
.option("--env-path <path>", "Qwen Code .env path")
.option("--yes", "Non-interactive; requires --model")
.option("--dry-run", "Print settings without writing files or secrets")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id for Qwen (required unless picked interactively)")
.option("--config-path <path>", "settings.json path (default: ~/.qwen/settings.json)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.action(async (opts) => {
const code = await runSetupQwenCommand(opts);
if (code !== 0) process.exitCode = code;
if (code !== 0) process.exit(code);
});
}

View File

@@ -26,7 +26,6 @@ function wantsProviderSetup(opts) {
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");

View File

@@ -8,7 +8,6 @@ import {
sleep,
} from "../utils/pid.mjs";
import { t } from "../i18n.mjs";
import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts";
const execFileAsync = promisify(execFile);
@@ -28,11 +27,18 @@ export async function runStopCommand(opts = {}) {
if (pid && isPidRunning(pid)) {
console.log(t("stop.stopping", { pid }));
try {
// #8045: on win32, process.kill(pid, "SIGTERM") unconditionally force-terminates
// the target instead of delivering an interceptable signal, racing (and beating)
// the server's own async graceful shutdown / WAL checkpoint. stopProcessGracefully
// skips the immediate SIGTERM on win32 and just polls before escalating to SIGKILL.
await stopProcessGracefully({ pid, timeoutMs: 5000, isPidRunning, sleep });
process.kill(pid, "SIGTERM");
let waited = 0;
while (waited < 5000 && isPidRunning(pid)) {
await sleep(100);
waited += 100;
}
if (isPidRunning(pid)) {
process.kill(pid, "SIGKILL");
await sleep(500);
}
killAllSubprocesses();
cleanupPidFile("server");

View File

@@ -133,20 +133,6 @@
"listTitle": "Keys expiring within {days} days:"
}
},
"authExport": {
"description": "Export DECRYPTED provider credentials (local-only, plaintext output)",
"idOpt": "Export only the connection matching this id/name/provider",
"formatOpt": "Output format: json or env",
"outOpt": "Write output to a file instead of stdout (written with 0600 permissions)",
"forceOpt": "Confirm you understand this prints/writes plaintext secrets",
"warning": "⚠ This prints/writes DECRYPTED plaintext API keys and OAuth tokens. Make sure your screen, shell history, and any output file stay private.",
"confirmHeading": "⚠ WARNING: this exports DECRYPTED provider credentials in plaintext",
"confirmBody": "This command decrypts and prints/writes apiKey, accessToken, refreshToken, and\nidToken for the selected connection(s). Treat the output as a secret.",
"confirmFooter": "To confirm, run:\n omniroute auth export --force",
"missingKey": "STORAGE_ENCRYPTION_KEY is required to export credentials.",
"notFound": "Connection not found: {id}",
"invalidFormat": "Invalid format: {format}. Use json or env."
},
"stream": {
"description": "Stream a chat response with SSE inspection modes",
"file": "Read prompt from file",

View File

@@ -132,20 +132,6 @@
"listTitle": "Chaves que expiram nos próximos {days} dias:"
}
},
"authExport": {
"description": "Exportar credenciais DESCRIPTOGRAFADAS de provedores (somente local, saída em texto puro)",
"idOpt": "Exportar apenas a conexão correspondente a este id/nome/provedor",
"formatOpt": "Formato de saída: json ou env",
"outOpt": "Gravar a saída em um arquivo em vez do stdout (gravado com permissão 0600)",
"forceOpt": "Confirma que você entende que isso imprime/grava segredos em texto puro",
"warning": "⚠ Isso imprime/grava chaves de API e tokens OAuth DESCRIPTOGRAFADOS em texto puro. Garanta que sua tela, o histórico do shell e qualquer arquivo de saída permaneçam privados.",
"confirmHeading": "⚠ AVISO: isso exporta credenciais de provedores DESCRIPTOGRAFADAS em texto puro",
"confirmBody": "Este comando descriptografa e imprime/grava apiKey, accessToken, refreshToken e\nidToken da(s) conexão(ões) selecionada(s). Trate a saída como um segredo.",
"confirmFooter": "Para confirmar, execute:\n omniroute auth export --force",
"missingKey": "STORAGE_ENCRYPTION_KEY é obrigatório para exportar credenciais.",
"notFound": "Conexão não encontrada: {id}",
"invalidFormat": "Formato inválido: {format}. Use json ou env."
},
"stream": {
"description": "Transmitir resposta de chat com modos de inspeção SSE",
"file": "Ler prompt de arquivo",

View File

@@ -29,16 +29,16 @@
"setup": {
"title": "OmniRoute 设置",
"passwordPrompt": "管理员密码",
"providerPrompt": "默认提供(留空跳过)",
"providerPrompt": "默认提供(留空跳过)",
"done": "设置完成",
"passwordSet": "管理员密码已配置",
"providerSet": "提供已配置:{name}",
"testingProvider": "正在测试提供连接:{name}",
"testPassed": "提供测试通过",
"testFailed": "提供测试失败:{error}",
"providerSet": "提供已配置:{name}",
"testingProvider": "正在测试提供连接:{name}",
"testPassed": "提供测试通过",
"testFailed": "提供测试失败:{error}",
"loginEnabled": "登录:已启用(密码已更新)",
"loginDisabled": "登录:已禁用",
"providerInfo": "提供{info}"
"providerInfo": "提供{info}"
},
"doctor": {
"title": "OmniRoute 诊断",
@@ -52,29 +52,29 @@
"warnings": "{count} 个警告 — 见上文。"
},
"providers": {
"title": "提供",
"noProviders": "未配置提供。运行omniroute setup",
"title": "提供",
"noProviders": "未配置提供。运行omniroute setup",
"testing": "正在测试 {name}...",
"available": "{count} 个提供可用",
"available": "{count} 个提供可用",
"connected": "已连接",
"disconnected": "未连接",
"validationFailed": "验证失败:{error}",
"metrics": {
"description": "显示提供性能指标(延迟、成功率、成本)",
"provider": "按提供 ID 筛选",
"description": "显示提供性能指标(延迟、成功率、成本)",
"provider": "按提供 ID 筛选",
"connection_id": "按连接 ID 筛选",
"period": "时间范围1h|6h|24h|7d|30d默认24h",
"metric": "关注特定指标字段",
"sort": "按字段排序(降序)",
"limit": "最大行数默认50",
"watch": "每 5 秒刷新(实时模式)",
"compare": "逗号分隔的提供 ID并排比较"
"compare": "逗号分隔的提供 ID并排比较"
},
"metric_single": {
"description": "获取特定连接的单个指标值"
},
"rotate": {
"description": "轮换提供连接的上游 API 密钥",
"description": "轮换提供连接的上游 API 密钥",
"newKeyOpt": "新 API 密钥值(避免使用:优先使用 --from-env",
"fromEnvOpt": "从环境变量 VAR 读取新密钥",
"oauthOpt": "改为触发 OAuth 重新认证流程",
@@ -89,18 +89,18 @@
"testFailed": "轮换后测试失败:{error}"
},
"status": {
"description": "显示所有提供连接的密钥健康状态(期限、过期、冷却)",
"providerOpt": "按提供名称筛选",
"header": "ID 提供 名称 过期状态 测试状态 冷却至",
"noData": "没有可用的提供连接数据。",
"description": "显示所有提供连接的密钥健康状态(期限、过期、冷却)",
"providerOpt": "按提供名称筛选",
"header": "ID 提供 名称 过期状态 测试状态 冷却至",
"noData": "没有可用的提供连接数据。",
"requiresServer": "providers status 需要 OmniRoute 服务器正在运行。"
}
},
"keys": {
"title": "API 密钥",
"addDescription": "为提供添加或更新 API 密钥",
"addDescription": "为提供添加或更新 API 密钥",
"listDescription": "列出所有已配置的 API 密钥",
"removeDescription": "移除提供的 API 密钥",
"removeDescription": "移除提供的 API 密钥",
"regenerateDescription": "重新生成 OmniRoute API 密钥",
"revokeDescription": "撤销 OmniRoute API 密钥",
"revealDescription": "显示未掩码的 API 密钥值",
@@ -122,10 +122,10 @@
"revoked": "密钥 {id} 已撤销。",
"rotated": "密钥 {id} 已轮换。新密钥 ID{newId}",
"revealWarning": "⚠ 这将显示完整的未掩码密钥。请确保您的屏幕不被人看到。",
"providerRequired": "需要提供。",
"providerRequired": "需要提供。",
"keyRequired": "需要 API 密钥。",
"stdinEmpty": "未通过标准输入提供 API 密钥。",
"unknownProvider": "未知提供{provider}",
"unknownProvider": "未知提供{provider}",
"policy": {
"title": "密钥策略",
"showDescription": "显示密钥的速率/成本策略",
@@ -165,7 +165,7 @@
"analytics": {
"description": "显示汇总使用分析",
"period": "时间范围1d|7d|30d|90d|ytd|all默认30d",
"provider": "按提供 ID 筛选"
"provider": "按提供 ID 筛选"
},
"budget": {
"description": "管理成本预算",
@@ -175,8 +175,8 @@
}
},
"quota": {
"description": "显示提供配额使用情况",
"provider": "按提供 ID 筛选",
"description": "显示提供配额使用情况",
"provider": "按提供 ID 筛选",
"check": "显示新请求是否有可用配额"
},
"logs": {
@@ -201,7 +201,7 @@
}
},
"cost": {
"description": "按提供、模型、组合或 API 密钥显示成本报告",
"description": "按提供、模型、组合或 API 密钥显示成本报告",
"period": "时间范围1d|7d|30d|90d|ytd|all默认30d",
"since": "开始日期ISO 格式,例如 2026-01-01— 覆盖 --period",
"until": "结束日期ISO 格式)",
@@ -210,7 +210,7 @@
"limit": "显示的最大行数默认100"
},
"simulate": {
"description": "模拟路由(空运行)— 显示将选择哪些提供而不调用上游",
"description": "模拟路由(空运行)— 显示将选择哪些提供而不调用上游",
"file": "从 JSON 文件加载完整请求体",
"model": "模型 ID默认auto",
"combo": "强制指定组合名称",
@@ -301,7 +301,7 @@
"cost": "成本24h"
},
"quota": {
"description": "显示提供配额使用情况",
"description": "显示提供配额使用情况",
"noServer": "服务器未运行。启动omniroute serve",
"noData": "没有可用的配额信息。"
},
@@ -315,12 +315,12 @@
"description": "一键启动本地 Redis 容器Podman 或 Docker用于 OmniRoute 缓存和配额跟踪"
},
"test": {
"description": "测试提供连接",
"description": "测试提供连接",
"noServer": "服务器未运行。启动omniroute serve",
"testing": "正在测试 {provider} / {model}...",
"passed": "连接成功!",
"failed": "连接失败:{error}",
"allProvidersOpt": "测试所有已配置的提供",
"allProvidersOpt": "测试所有已配置的提供",
"latencyOpt": "显示延迟测量值(平均/最小/最大 毫秒)",
"repeatOpt": "重复测试 N 次并汇总结果",
"compareOpt": "逗号分隔的要比较的模型(例如 gpt-4o,claude-3-5-sonnet",
@@ -328,7 +328,7 @@
"saved": "结果已保存至 {path}",
"compareTitle": "模型比较",
"compareMinTwo": "--compare 需要至少两个模型(逗号分隔)",
"noProviders": "未配置提供。添加omniroute keys add"
"noProviders": "未配置提供。添加omniroute keys add"
},
"update": {
"checking": "正在检查更新...",
@@ -509,7 +509,7 @@
},
"models": {
"description": "列出可用模型(需要服务器)",
"search": "按 ID、名称、提供或描述筛选模型",
"search": "按 ID、名称、提供或描述筛选模型",
"noServer": "服务器未运行。启动omniroute serve",
"noModels": "未找到模型。"
},
@@ -656,25 +656,25 @@
}
},
"oauth": {
"description": "管理 OAuth 提供连接",
"description": "管理 OAuth 提供连接",
"providers": {
"description": "列出支持 OAuth 的提供及其流程类型"
"description": "列出支持 OAuth 的提供及其流程类型"
},
"start": {
"description": "为提供启动 OAuth 授权流程",
"provider": "提供 IDgemini, copilot, cursor, ...",
"description": "为提供启动 OAuth 授权流程",
"provider": "提供 IDgemini, copilot, cursor, ...",
"no_browser": "仅打印 URL — 不打开浏览器",
"import_system": "从本地系统配置自动导入凭据",
"social": "社交登录提供google|github— kiro 需要",
"social": "社交登录提供google|github— kiro 需要",
"timeout": "等待授权超时时间毫秒默认300000"
},
"status": {
"description": "列出活动的 OAuth 连接",
"provider": "按提供 ID 筛选"
"provider": "按提供 ID 筛选"
},
"revoke": {
"description": "撤销 OAuth 连接",
"provider": "要撤销的提供 ID",
"provider": "要撤销的提供 ID",
"connection_id": "按 ID 撤销特定连接",
"yes": "跳过确认提示"
}
@@ -884,11 +884,11 @@
"description": "管理模型定价数据",
"sync": {
"description": "从上游同步价格",
"provider": "按提供筛选",
"provider": "按提供筛选",
"force": "强制重新同步"
},
"list": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"model": "按模型筛选",
"limit": "最大结果数"
},
@@ -907,25 +907,25 @@
"resilience": {
"description": "检查和管理弹性机制",
"status": {
"provider": "按提供筛选"
"provider": "按提供筛选"
},
"breakers": {
"provider": "按提供筛选"
"provider": "按提供筛选"
},
"cooldowns": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"connectionId": "按连接 ID 筛选"
},
"lockouts": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"model": "按模型筛选"
},
"reset": {
"description": "重置断路器/冷却状态",
"provider": "要重置的提供",
"provider": "要重置的提供",
"connectionId": "要重置的连接 ID",
"model": "要重置锁定状态的模型",
"allCooldowns": "重置提供的所有冷却",
"allCooldowns": "重置提供的所有冷却",
"yes": "跳过确认"
},
"profile": {
@@ -940,13 +940,13 @@
}
},
"nodes": {
"description": "管理提供节点(端点)",
"description": "管理提供节点(端点)",
"list": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"enabled": "仅显示已启用的节点"
},
"add": {
"provider": "提供名称",
"provider": "提供名称",
"baseUrl": "节点的基础 URL",
"name": "节点名称",
"weight": "负载均衡权重",
@@ -965,7 +965,7 @@
},
"validate": {
"baseUrl": "要验证的 URL",
"provider": "要验证的提供"
"provider": "要验证的提供"
},
"test": {
"description": "向节点发送测试请求"
@@ -1091,7 +1091,7 @@
"oneproxy": {
"description": "管理 OneProxy 上游代理池",
"stats": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"period": "时间范围默认24h"
},
"fetch": {
@@ -1101,14 +1101,14 @@
},
"rotate": {
"description": "强制轮换代理",
"provider": "要轮换的提供",
"provider": "要轮换的提供",
"connectionId": "要轮换的特定连接 ID"
},
"config": {
"description": "显示或更新 OneProxy 配置",
"enabled": "启用代理池true|false",
"poolSize": "池大小",
"providerSource": "代理提供的 URL",
"providerSource": "代理提供的 URL",
"rotationPolicy": "轮换策略sticky|per-request|periodic"
},
"pool": {
@@ -1201,7 +1201,7 @@
"bash": "打印 bash 补全脚本",
"fish": "打印 fish 补全脚本",
"install": "为检测到的 Shell 全局安装补全脚本",
"refresh": "刷新组合/提供/模型缓存"
"refresh": "刷新组合/提供/模型缓存"
},
"logs": {
"description": "流式传输或导出请求日志",

View File

@@ -29,16 +29,16 @@
"setup": {
"title": "OmniRoute 設定",
"passwordPrompt": "管理員密碼",
"providerPrompt": "預設提供(留空跳過)",
"providerPrompt": "預設提供(留空跳過)",
"done": "設定完成",
"passwordSet": "管理員密碼已配置",
"providerSet": "提供已配置:{name}",
"testingProvider": "正在測試提供連線:{name}",
"testPassed": "提供測試通過",
"testFailed": "提供測試失敗:{error}",
"providerSet": "提供已配置:{name}",
"testingProvider": "正在測試提供連線:{name}",
"testPassed": "提供測試通過",
"testFailed": "提供測試失敗:{error}",
"loginEnabled": "登入:已啟用(密碼已更新)",
"loginDisabled": "登入:已停用",
"providerInfo": "提供{info}"
"providerInfo": "提供{info}"
},
"doctor": {
"title": "OmniRoute 診斷",
@@ -52,29 +52,29 @@
"warnings": "{count} 個警告 — 見上文。"
},
"providers": {
"title": "提供",
"noProviders": "未配置提供。執行omniroute setup",
"title": "提供",
"noProviders": "未配置提供。執行omniroute setup",
"testing": "正在測試 {name}...",
"available": "{count} 個提供可用",
"available": "{count} 個提供可用",
"connected": "已連線",
"disconnected": "未連線",
"validationFailed": "驗證失敗:{error}",
"metrics": {
"description": "顯示提供效能指標(延遲、成功率、成本)",
"provider": "按提供 ID 篩選",
"description": "顯示提供效能指標(延遲、成功率、成本)",
"provider": "按提供 ID 篩選",
"connection_id": "按連線 ID 篩選",
"period": "時間範圍1h|6h|24h|7d|30d預設24h",
"metric": "關注特定指標欄位",
"sort": "按欄位排序(降序)",
"limit": "最大行數預設50",
"watch": "每 5 秒重新整理(即時模式)",
"compare": "逗號分隔的提供 ID並排比較"
"compare": "逗號分隔的提供 ID並排比較"
},
"metric_single": {
"description": "獲取特定連線的單個指標值"
},
"rotate": {
"description": "輪換提供連線的上游 API 金鑰",
"description": "輪換提供連線的上游 API 金鑰",
"newKeyOpt": "新 API 金鑰值(避免使用:優先使用 --from-env",
"fromEnvOpt": "從環境變數 VAR 讀取新金鑰",
"oauthOpt": "改為觸發 OAuth 重新認證流程",
@@ -89,18 +89,18 @@
"testFailed": "輪換後測試失敗:{error}"
},
"status": {
"description": "顯示所有提供連線的金鑰健康狀態(期限、過期、冷卻)",
"providerOpt": "按提供名稱篩選",
"header": "ID 提供 名稱 過期狀態 測試狀態 冷卻至",
"noData": "沒有可用的提供連線資料。",
"description": "顯示所有提供連線的金鑰健康狀態(期限、過期、冷卻)",
"providerOpt": "按提供名稱篩選",
"header": "ID 提供 名稱 過期狀態 測試狀態 冷卻至",
"noData": "沒有可用的提供連線資料。",
"requiresServer": "providers status 需要 OmniRoute 伺服器正在執行。"
}
},
"keys": {
"title": "API 金鑰",
"addDescription": "為提供新增或更新 API 金鑰",
"addDescription": "為提供新增或更新 API 金鑰",
"listDescription": "列出所有已配置的 API 金鑰",
"removeDescription": "移除提供的 API 金鑰",
"removeDescription": "移除提供的 API 金鑰",
"regenerateDescription": "重新生成 OmniRoute API 金鑰",
"revokeDescription": "撤銷 OmniRoute API 金鑰",
"revealDescription": "顯示未掩碼的 API 金鑰值",
@@ -122,10 +122,10 @@
"revoked": "金鑰 {id} 已撤銷。",
"rotated": "金鑰 {id} 已輪換。新金鑰 ID{newId}",
"revealWarning": "⚠ 這將顯示完整的未掩碼金鑰。請確保您的螢幕不被人看到。",
"providerRequired": "需要提供。",
"providerRequired": "需要提供。",
"keyRequired": "需要 API 金鑰。",
"stdinEmpty": "未通過標準輸入提供 API 金鑰。",
"unknownProvider": "未知提供{provider}",
"unknownProvider": "未知提供{provider}",
"policy": {
"title": "金鑰策略",
"showDescription": "顯示金鑰的速率/成本策略",
@@ -151,7 +151,7 @@
"model": "模型 ID預設auto",
"system": "系統提示",
"combo": "強制指定組合名稱",
"max_tokens": "響應最大權杖數",
"max_tokens": "響應最大令牌數",
"responses_api": "使用 /v1/responses 而不是 /v1/chat/completions",
"raw": "列印接收到的原始 SSE 行",
"debug": "在 stderr 中列印每塊的時間資訊",
@@ -165,7 +165,7 @@
"analytics": {
"description": "顯示彙總使用分析",
"period": "時間範圍1d|7d|30d|90d|ytd|all預設30d",
"provider": "按提供 ID 篩選"
"provider": "按提供 ID 篩選"
},
"budget": {
"description": "管理成本預算",
@@ -175,8 +175,8 @@
}
},
"quota": {
"description": "顯示提供配額使用情況",
"provider": "按提供 ID 篩選",
"description": "顯示提供配額使用情況",
"provider": "按提供 ID 篩選",
"check": "顯示新請求是否有可用配額"
},
"logs": {
@@ -201,7 +201,7 @@
}
},
"cost": {
"description": "按提供、模型、組合或 API 金鑰顯示成本報告",
"description": "按提供、模型、組合或 API 金鑰顯示成本報告",
"period": "時間範圍1d|7d|30d|90d|ytd|all預設30d",
"since": "開始日期ISO 格式,例如 2026-01-01— 覆蓋 --period",
"until": "結束日期ISO 格式)",
@@ -210,12 +210,12 @@
"limit": "顯示的最大行數預設100"
},
"simulate": {
"description": "模擬路由(空執行)— 顯示將選擇哪些提供而不呼叫上游",
"description": "模擬路由(空執行)— 顯示將選擇哪些提供而不呼叫上游",
"file": "從 JSON 檔案載入完整請求體",
"model": "模型 ID預設auto",
"combo": "強制指定組合名稱",
"reasoning": "推理努力級別low|medium|high",
"thinking": "擴充套件思維權杖預算",
"thinking": "擴充套件思維令牌預算",
"explain": "在 stderr 中列印回退樹和成本範圍",
"noCombo": "未找到匹配的組合。使用以下命令配置omniroute combo create"
},
@@ -225,11 +225,11 @@
"stdin": "從標準輸入讀取提示",
"system": "系統提示",
"model": "模型 ID預設auto",
"max_tokens": "響應最大權杖數",
"max_tokens": "響應最大令牌數",
"temperature": "取樣溫度02",
"top_p": "Top-p 核取樣",
"reasoning_effort": "推理努力級別low|medium|high",
"thinking_budget": "擴充套件思維權杖預算",
"thinking_budget": "擴充套件思維令牌預算",
"combo": "強制指定組合名稱",
"responses_api": "使用 /v1/responses 而不是 /v1/chat/completions",
"stream": "增量流式傳輸響應",
@@ -301,7 +301,7 @@
"cost": "成本24h"
},
"quota": {
"description": "顯示提供配額使用情況",
"description": "顯示提供配額使用情況",
"noServer": "伺服器未執行。啟動omniroute serve",
"noData": "沒有可用的配額資訊。"
},
@@ -315,12 +315,12 @@
"description": "一鍵啟動本地 Redis 容器Podman 或 Docker用於 OmniRoute 快取和配額跟蹤"
},
"test": {
"description": "測試提供連線",
"description": "測試提供連線",
"noServer": "伺服器未執行。啟動omniroute serve",
"testing": "正在測試 {provider} / {model}...",
"passed": "連線成功!",
"failed": "連線失敗:{error}",
"allProvidersOpt": "測試所有已配置的提供",
"allProvidersOpt": "測試所有已配置的提供",
"latencyOpt": "顯示延遲測量值(平均/最小/最大 毫秒)",
"repeatOpt": "重複測試 N 次並彙總結果",
"compareOpt": "逗號分隔的要比較的模型(例如 gpt-4o,claude-3-5-sonnet",
@@ -328,7 +328,7 @@
"saved": "結果已儲存至 {path}",
"compareTitle": "模型比較",
"compareMinTwo": "--compare 需要至少兩個模型(逗號分隔)",
"noProviders": "未配置提供。新增omniroute keys add"
"noProviders": "未配置提供。新增omniroute keys add"
},
"update": {
"checking": "正在檢查更新...",
@@ -445,7 +445,7 @@
"description": "配置壓縮設定",
"engine": "壓縮引擎caveman|rtk|hybrid|none",
"caveman_agg": "Caveman 激程序度 0.01.0",
"rtk_budget": "RTK 權杖預算",
"rtk_budget": "RTK 令牌預算",
"language_pack": "要啟用的語言包"
},
"engine": {
@@ -509,7 +509,7 @@
},
"models": {
"description": "列出可用模型(需要伺服器)",
"search": "按 ID、名稱、提供或描述篩選模型",
"search": "按 ID、名稱、提供或描述篩選模型",
"noServer": "伺服器未執行。啟動omniroute serve",
"noModels": "未找到模型。"
},
@@ -621,7 +621,7 @@
"type": "按記憶型別篩選user|feedback|project|reference",
"limit": "最大結果數預設20",
"api_key": "按 API 金鑰篩選",
"token_budget": "限制結果中的總權杖數"
"token_budget": "限制結果中的總令牌數"
},
"add": {
"description": "新增新的記憶條目",
@@ -656,25 +656,25 @@
}
},
"oauth": {
"description": "管理 OAuth 提供連線",
"description": "管理 OAuth 提供連線",
"providers": {
"description": "列出支援 OAuth 的提供及其流程型別"
"description": "列出支援 OAuth 的提供及其流程型別"
},
"start": {
"description": "為提供啟動 OAuth 授權流程",
"provider": "提供 IDgemini, copilot, cursor, ...",
"description": "為提供啟動 OAuth 授權流程",
"provider": "提供 IDgemini, copilot, cursor, ...",
"no_browser": "僅列印 URL — 不開啟瀏覽器",
"import_system": "從本地系統配置自動匯入憑據",
"social": "社交登入提供google|github— kiro 需要",
"social": "社交登入提供google|github— kiro 需要",
"timeout": "等待授權超時時間毫秒預設300000"
},
"status": {
"description": "列出活動的 OAuth 連線",
"provider": "按提供 ID 篩選"
"provider": "按提供 ID 篩選"
},
"revoke": {
"description": "撤銷 OAuth 連線",
"provider": "要撤銷的提供 ID",
"provider": "要撤銷的提供 ID",
"connection_id": "按 ID 撤銷特定連線",
"yes": "跳過確認提示"
}
@@ -884,20 +884,20 @@
"description": "管理模型定價資料",
"sync": {
"description": "從上游同步價格",
"provider": "按提供篩選",
"provider": "按提供篩選",
"force": "強制重新同步"
},
"list": {
"provider": "按提供篩選",
"provider": "按提供篩選",
"model": "按模型篩選",
"limit": "最大結果數"
},
"defaults": {
"description": "管理預設定價",
"input": "每 1M 權杖的輸入成本(美元)",
"output": "每 1M 權杖的輸出成本(美元)",
"cacheRead": "每 1M 權杖的快取讀取成本(美元)",
"cacheWrite": "每 1M 權杖的快取寫入成本(美元)"
"input": "每 1M 令牌的輸入成本(美元)",
"output": "每 1M 令牌的輸出成本(美元)",
"cacheRead": "每 1M 令牌的快取讀取成本(美元)",
"cacheWrite": "每 1M 令牌的快取寫入成本(美元)"
},
"diff": {
"description": "顯示與上游價格的差異",
@@ -907,25 +907,25 @@
"resilience": {
"description": "檢查和管理彈性機制",
"status": {
"provider": "按提供篩選"
"provider": "按提供篩選"
},
"breakers": {
"provider": "按提供篩選"
"provider": "按提供篩選"
},
"cooldowns": {
"provider": "按提供篩選",
"provider": "按提供篩選",
"connectionId": "按連線 ID 篩選"
},
"lockouts": {
"provider": "按提供篩選",
"provider": "按提供篩選",
"model": "按模型篩選"
},
"reset": {
"description": "重置斷路器/冷卻狀態",
"provider": "要重置的提供",
"provider": "要重置的提供",
"connectionId": "要重置的連線 ID",
"model": "要重置鎖定狀態的模型",
"allCooldowns": "重置提供的所有冷卻",
"allCooldowns": "重置提供的所有冷卻",
"yes": "跳過確認"
},
"profile": {
@@ -940,13 +940,13 @@
}
},
"nodes": {
"description": "管理提供節點(端點)",
"description": "管理提供節點(端點)",
"list": {
"provider": "按提供篩選",
"provider": "按提供篩選",
"enabled": "僅顯示已啟用的節點"
},
"add": {
"provider": "提供名稱",
"provider": "提供名稱",
"baseUrl": "節點的基礎 URL",
"name": "節點名稱",
"weight": "負載均衡權重",
@@ -965,7 +965,7 @@
},
"validate": {
"baseUrl": "要驗證的 URL",
"provider": "要驗證的提供"
"provider": "要驗證的提供"
},
"test": {
"description": "向節點發送測試請求"
@@ -993,7 +993,7 @@
"description": "管理 RTK 上下文最佳化器",
"config": {
"description": "顯示或更新 RTK 配置",
"tokenBudget": "RTK 權杖預算",
"tokenBudget": "RTK 令牌預算",
"reservePct": "保留百分比"
},
"filters": {
@@ -1091,7 +1091,7 @@
"oneproxy": {
"description": "管理 OneProxy 上游代理池",
"stats": {
"provider": "按提供篩選",
"provider": "按提供篩選",
"period": "時間範圍預設24h"
},
"fetch": {
@@ -1101,14 +1101,14 @@
},
"rotate": {
"description": "強制輪換代理",
"provider": "要輪換的提供",
"provider": "要輪換的提供",
"connectionId": "要輪換的特定連線 ID"
},
"config": {
"description": "顯示或更新 OneProxy 配置",
"enabled": "啟用代理池true|false",
"poolSize": "池大小",
"providerSource": "代理提供的 URL",
"providerSource": "代理提供的 URL",
"rotationPolicy": "輪換策略sticky|per-request|periodic"
},
"pool": {
@@ -1163,11 +1163,11 @@
"fromCloud": "從雲備份初始化"
},
"tokens": {
"description": "管理同步權杖",
"description": "管理同步令牌",
"create": {
"name": "權杖名稱",
"scope": "權杖作用域",
"ttl": "權杖有效期(例如 30d"
"name": "令牌名稱",
"scope": "令牌作用域",
"ttl": "令牌有效期(例如 30d"
},
"revoke": {
"yes": "跳過確認"
@@ -1201,7 +1201,7 @@
"bash": "列印 bash 補全指令碼",
"fish": "列印 fish 補全指令碼",
"install": "為檢測到的 Shell 全域性安裝補全指令碼",
"refresh": "重新整理組合/提供/模型快取"
"refresh": "重新整理組合/提供/模型快取"
},
"logs": {
"description": "流式傳輸或匯出請求日誌",

View File

@@ -52,31 +52,6 @@ export function hasModule(name) {
return existsSync(join(runtimeModules(), name, "package.json"));
}
/**
* Probe whether a native addon (.node) file can actually be dlopen'd by the Node runtime that
* is going to load it. Runs in a throwaway subprocess so a real ABI mismatch (which can segfault
* the process instead of throwing) never takes down the caller — only the probe subprocess.
*/
function probeNativeBinaryLoadable(binary) {
try {
const res = spawnSync(
process.execPath,
[
"-e",
"try { require(process.argv[1]); process.exit(0); } catch (e) { process.exit(1); }",
binary,
],
{ timeout: 10_000, stdio: "ignore" }
);
// status === 0 means require() (and therefore dlopen) succeeded. Anything else — a thrown
// ERR_DLOPEN_FAILED/NODE_MODULE_VERSION mismatch (status 1) or a crash (status null with a
// signal, e.g. SIGSEGV) — means the binary is not safe to load.
return res.status === 0;
} catch {
return false;
}
}
export function isBetterSqliteBinaryValid() {
const binary = join(
runtimeModules(),
@@ -93,18 +68,10 @@ export function isBetterSqliteBinaryValid() {
closeSync(fd);
const magic = buf.toString("hex");
const os = platform();
let formatOk;
if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF
else if (os === "darwin")
formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ
else formatOk = true;
if (!formatOk) return false;
// File-format magic bytes alone do not guarantee the binary was built for the Node ABI
// (NODE_MODULE_VERSION) that will load it — a stale/foreign-ABI binary passes the header
// check and then crashes (segfault) on load instead of triggering a rebuild. Actually
// attempt to load it, isolated in a subprocess.
return probeNativeBinaryLoadable(binary);
if (os === "linux") return magic.startsWith("7f454c46"); // ELF
if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ
return true;
} catch {
return false;
}

View File

@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { dirname } from "node:path";
import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs";
import { writePidFile, cleanupPidFile, killAllSubprocesses } from "../utils/pid.mjs";
import {
RESTART_RESET_MS,
DEFAULT_MAX_RESTARTS,
@@ -9,7 +9,6 @@ import {
waitUntilPortFree,
} from "./supervisorPolicy.mjs";
import { buildNodeHeapArgs } from "../../../scripts/build/runtime-env.mjs";
import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts";
const CRASH_LOG_LINES = 50;
@@ -41,10 +40,7 @@ export class ServerSupervisor {
// silently, so a boot that never becomes ready looked like a dead hang with zero
// output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside
// stderr so a readiness timeout can surface what the child actually printed.
this.child = spawn(process.versions.bun ? process.execPath : "node", [
...(process.versions.bun ? [] : heapArgs),
this.serverPath,
], {
this.child = spawn("node", [...heapArgs, this.serverPath], {
cwd: dirname(this.serverPath),
env: this.env,
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
@@ -73,32 +69,12 @@ export class ServerSupervisor {
return this.child;
}
handleExit(code, err) {
handleExit(code) {
// Node.js v24+ requires process.exit() to receive a number. Spawn-error events
// deliver err.code (a string like 'ENOENT') via the 'error' listener; normalise here.
const exitCode = typeof code === "number" ? code : null;
cleanupPidFile("server");
// #8091: the child's spawn 'error' listener passes `err` through as a second
// argument, but it used to be silently dropped — the user only ever saw the
// hardcoded "code=-1" with a permanently empty crash log, with no way to
// diagnose why the child never started (ENOENT/EACCES/bad path/etc.). Surface
// the real reason immediately, both on the console and in the crash-log buffer
// so `dumpCrashLog()` shows it too.
if (err) {
const detail = [
err.code && `code=${err.code}`,
err.syscall && `syscall=${err.syscall}`,
err.path && `path=${err.path}`,
err.message,
]
.filter(Boolean)
.join(" ");
const line = `⚠ Spawn error: ${detail || String(err)}`;
console.error(line);
this.crashLog.push(line);
}
// #4425: only exit on an intentional shutdown. A spontaneous code-0 exit (e.g. a
// systemd MemoryMax cgroup kill, which reports the process exited cleanly) is anomalous
// and must be restarted, not treated as a graceful stop that leaves the gateway dead.
@@ -156,13 +132,14 @@ export class ServerSupervisor {
stop() {
this.isShuttingDown = true;
if (this.child?.pid) {
// #8045: on win32, process.kill(pid, "SIGTERM") unconditionally force-terminates
// the target — it is never a real, interceptable signal there. The child already
// receives the real CTRL_C_EVENT/CTRL_CLOSE_EVENT independently (it shares the
// console) and runs its own async graceful shutdown (WAL checkpoint). Sending
// SIGTERM immediately on win32 races and beats that cleanup. Fire-and-forget:
// stop() itself stays sync so callers keep their existing control flow.
void stopProcessGracefully({ pid: this.child.pid, timeoutMs: 5000, isPidRunning });
try {
process.kill(this.child.pid, "SIGTERM");
} catch {}
setTimeout(() => {
try {
process.kill(this.child.pid, "SIGKILL");
} catch {}
}, 5000);
}
killAllSubprocesses();
}

View File

@@ -3,85 +3,12 @@ import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs";
import { ensureProviderSchema } from "./provider-store.mjs";
import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./settings-store.mjs";
async function loadSqlite() {
if (process.versions.bun) {
return { Database: (await import("bun:sqlite")).Database };
}
async function loadBetterSqlite() {
try {
return { Database: (await import("better-sqlite3")).default };
} catch (error) {
return { error };
}
}
// #7586: unlike the real server (src/lib/db/adapters/driverFactory.ts::tryOpenSync),
// this CLI helper historically had NO fallback beyond better-sqlite3 — so on any
// machine where better-sqlite3's native binary is unavailable (Windows without a
// prebuilt addon, etc.), every `omniroute doctor` DB check reported a false FAIL
// even when the actual server was healthy via its own (correct) driver cascade.
// Reuse that same cascade here instead of re-deriving it.
async function openWithSyncDriverFallback(dbPath, options, importError) {
try {
const { tryOpenSync } = await import("../../src/lib/db/adapters/driverFactory.ts");
const adapter = tryOpenSync(dbPath, options);
if (adapter) {
return adapter;
}
return (await import("better-sqlite3")).default;
} catch {
// fall through to the original better-sqlite3 error below
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
}
throw createSqliteNativeError(importError);
}
function openBunSqlite(Database, dbPath, options) {
const raw = new Database(dbPath, options);
const prepare = (sql) => {
const statement = raw.query(sql);
return {
run: (...params) => statement.run(...normalizeBunSqliteParams(params)),
get: (...params) => statement.get(...normalizeBunSqliteParams(params)),
all: (...params) => statement.all(...normalizeBunSqliteParams(params)),
};
};
return {
prepare,
query: (sql) => raw.query(sql),
exec: (sql) => raw.exec(sql),
transaction: (fn) => raw.transaction(fn),
close: () => raw.close(),
serialize: () => raw.serialize(),
pragma: (pragmaStr, pragmaOptions) => {
const statement = raw.query(`PRAGMA ${pragmaStr}`);
if (pragmaOptions?.simple) {
const row = statement.get();
return row ? (Object.values(row)[0] ?? null) : null;
}
return statement.all();
},
};
}
export function normalizeBunSqliteParams(params) {
if (
params.length !== 1 ||
params[0] === null ||
typeof params[0] !== "object" ||
Array.isArray(params[0]) ||
params[0] instanceof Uint8Array ||
(typeof Buffer !== "undefined" && Buffer.isBuffer(params[0]))
) {
return params;
}
const expanded = {};
for (const [key, value] of Object.entries(params[0])) {
if (/^[:@$]/.test(key)) expanded[key] = value;
else {
expanded[`@${key}`] = value;
expanded[`:${key}`] = value;
expanded[`$${key}`] = value;
}
}
return [expanded];
}
export function createSqliteNativeError(error) {
@@ -94,41 +21,13 @@ export function createSqliteNativeError(error) {
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
);
}
if (
message.includes("Could not locate the bindings file") ||
message.includes("MODULE_NOT_FOUND") ||
message.includes("Cannot find module 'better-sqlite3'")
) {
return new Error(
"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;
}
async function openSqliteDatabase(dbPath, options = {}) {
const loaded = await loadSqlite();
if (process.versions.bun) {
if (options.fileMustExist && !fs.existsSync(dbPath)) {
throw new Error(`SQLite file does not exist: ${dbPath}`);
}
const bunOptions = options.readonly
? { readonly: true }
: { readwrite: true, create: options.fileMustExist !== true };
try {
return openBunSqlite(loaded.Database, dbPath, bunOptions);
} catch (error) {
throw createSqliteNativeError(error);
}
}
if (loaded.error) {
return openWithSyncDriverFallback(dbPath, options, loaded.error);
}
const Database = await loadBetterSqlite();
try {
return new loaded.Database(dbPath, options);
return new Database(dbPath, options);
} catch (error) {
throw createSqliteNativeError(error);
}
@@ -160,16 +59,7 @@ export async function withReadonlySqlite(dbPath, callback) {
export async function backupSqliteFile(sourcePath, destPath) {
const db = await openSqliteDatabase(sourcePath, { readonly: true });
try {
if (typeof db.backup === "function") {
await db.backup(destPath);
} else if (sourcePath === ":memory:" && typeof db.serialize === "function") {
fs.writeFileSync(destPath, Buffer.from(db.serialize()));
} else {
try {
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
} catch {}
fs.copyFileSync(sourcePath, destPath);
}
await db.backup(destPath);
} finally {
db.close();
}

View File

@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
const APP_LABEL = "com.omniroute.autostart";
const WIN_REG_VALUE = "OmniRoute";
const WIN_STARTUP_FILE = "OmniRoute.vbs";
const LINUX_SERVICE_NAME = "omniroute.service";
const LINUX_DESKTOP_NAME = "omniroute.desktop";
@@ -324,101 +323,38 @@ function isEnabledMac() {
);
}
/**
* Returns the absolute path to the Windows Startup folder
* (%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\),
* or null when APPDATA is not set.
*/
function winStartupDir() {
if (!process.env.APPDATA) return null;
return join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
}
/**
* Returns the full path to the VBS autostart script in the Startup folder,
* or null when the Startup folder cannot be resolved.
*/
function winStartupPath() {
const dir = winStartupDir();
return dir ? join(dir, WIN_STARTUP_FILE) : null;
}
/**
* Builds the VBScript source that launches OmniRoute with WSH's Run method
* using SW_HIDE (0) so no console window appears.
*
* 9Router uses the same pattern: a .vbs file in the Startup folder that calls
* WScript.Shell.Run with window style 0 (hidden). This avoids the console
* window flash that HKCU\Run causes for console-mode node.exe.
*/
function buildWinVbsContent(cliPath) {
const execLine = buildServeExecLine(cliPath, { tray: true });
// VBScript doubles `"` inside a string to escape them. The execLine already
// contains quoted paths; escape each `"` to `""` so the VBS parser reads
// them as literal quote characters in the command string passed to Run().
const vbsSafe = execLine.replace(/"/g, '""');
return [
'Set WshShell = CreateObject("WScript.Shell")',
`WshShell.Run "${vbsSafe}", 0, False`,
"",
].join("\n");
}
/**
* Removes the legacy HKCU\Run registry value if present. Callers use this
* during migration so stale Run entries don't linger after switching to the
* VBS-based autostart.
*/
function cleanLegacyWinReg() {
try {
execSync(
`reg delete HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /f 2>nul`,
{ stdio: "ignore", windowsHide: true }
);
} catch {
// entry did not exist or already removed — noop
}
}
function enableWin() {
const cliPath = resolveCliPath();
if (!cliPath) return false;
const vbsPath = winStartupPath();
if (!vbsPath) return false;
const dir = dirname(vbsPath);
mkdirSync(dir, { recursive: true });
writeFileSync(vbsPath, buildWinVbsContent(cliPath), { mode: 0o644 });
// Migrate away from the legacy HKCU\Run entry — it launches node.exe with
// a visible console window. The VBS approach is the replacement.
cleanLegacyWinReg();
return existsSync(vbsPath);
const value = `"${process.execPath}" "${cliPath}" serve --tray --no-open`;
try {
execSync(
`reg add HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /t REG_SZ /d "${value}" /f`,
{ stdio: "ignore", windowsHide: true }
);
return true;
} catch {
return false;
}
}
function disableWin() {
const vbsPath = winStartupPath();
if (!vbsPath) return false;
try {
unlinkSync(vbsPath);
execSync(
`reg delete HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /f`,
{ stdio: "ignore", windowsHide: true }
);
return true;
} catch {
// already removed
return false;
}
// Also clean up any lingering legacy registry entry as a safety net.
cleanLegacyWinReg();
return !existsSync(vbsPath);
}
function isEnabledWin() {
const vbsPath = winStartupPath();
if (!vbsPath) return false;
// Primary check: VBS file exists in the Startup folder.
if (existsSync(vbsPath)) return true;
// Fallback check: legacy HKCU\Run entry (for users who enabled autostart
// before the VBS migration). Treat it as enabled so the tray menu shows
// the correct toggle state, and calling disable() will clean it up.
try {
const out = execSync(
`reg query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE}`,
{ stdio: "pipe", windowsHide: true, encoding: "utf8", timeout: 3000 }
{ stdio: "pipe", windowsHide: true, encoding: "utf8" }
);
return out.includes(WIN_REG_VALUE);
} catch {

View File

@@ -1,25 +0,0 @@
/**
* Decide whether a CLI invocation is a bare `--version`/`-V` query that should
* short-circuit BEFORE the runtime polyfill import, env-file loading, and
* Commander's command registration (~70 command modules) are loaded.
*
* Scope is intentionally narrow — only a single, unambiguous `--version`/`-V`
* argument fast-paths. Anything else (extra args, a subcommand, `--help`,
* global options like `--lang`/`--output` alongside it) falls through to the
* normal Commander flow. Unlike `--version`, OmniRoute's `--help` output is
* generated dynamically from every registered subcommand, so skipping
* registration would change (truncate) the help text — that flag is
* deliberately NOT fast-pathed here.
*
* Mirrors the intent of upstream 9router PR #2414 (fast-path help/version
* before expensive self-heal hooks), adapted to OmniRoute's Commander-based
* CLI where the equivalent expensive work is eager command registration
* rather than npm-install-based runtime self-healing.
*
* @param {string[]} argv - process.argv (node + script + args).
* @returns {boolean}
*/
export function isVersionFastPath(argv) {
const args = Array.isArray(argv) ? argv.slice(2) : [];
return args.length === 1 && (args[0] === "--version" || args[0] === "-V");
}

View File

@@ -4,9 +4,6 @@
* OmniRoute CLI entry point.
*
* Special bypasses (handled before Commander):
* --version / -V (alone) Fast-path: print the version and exit, skipping the
* tsx/esm + polyfill imports, env-file loading, and
* Commander's ~70-command registration entirely.
* --mcp Start MCP server over stdio
* reset-encrypted-columns Recovery tool for broken encrypted credentials
* reset-password Reset the admin/management password
@@ -14,7 +11,7 @@
* All other commands are routed through Commander (bin/cli/program.mjs).
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import updateNotifier from "update-notifier";
@@ -22,26 +19,6 @@ import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.
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";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..");
// Fast-path a bare `--version`/`-V` query BEFORE the tsx/esm registration, the
// polyfill import, env-file loading, or Commander's command registration (~70
// modules — DB, providers, OAuth, etc.) run. None of that work is needed to answer
// "what version is this" — mirrors upstream 9router PR #2414 (fast-path help/version
// ahead of expensive self-heal hooks), adapted to OmniRoute's Commander CLI where the
// equivalent expensive work is eager command registration rather than npm-install-based
// runtime self-healing. `--help` is intentionally NOT fast-pathed here: its output is
// generated dynamically from every registered subcommand, so skipping registration
// would truncate the help text instead of just speeding it up.
if (isVersionFastPath(process.argv)) {
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
console.log(pkg.version);
process.exit(0);
}
// Register tsx so dynamic imports of .ts source files (referenced as .js per
// TypeScript conventions) resolve correctly. The build never emits .js for
@@ -49,14 +26,9 @@ if (isVersionFastPath(process.argv)) {
await import("tsx/esm");
await import("../open-sse/utils/setupPolyfill.ts");
// #7791: tsx's tsconfig-path resolution does not apply when OmniRoute is
// installed globally (files live under node_modules/omniroute/), so bare
// `@/...` specifiers (declared in tsconfig.json paths as `@/* → ./src/*`)
// fail with ERR_MODULE_NOT_FOUND. Register an ESM resolve hook that maps
// `@/...` to absolute file URLs under <ROOT>/src/. Safe no-op in dev checkout
// (paths already resolve via tsconfig) and when ROOT has no `src/` dir.
const { registerAliasResolver } = await import("./aliasResolver.mjs");
await registerAliasResolver(ROOT);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..");
// MCP stdio transport uses stdout exclusively for JSON-RPC messages.
// Redirect console.log/warn to stderr early (before loadEnvFile and DB init)
@@ -68,28 +40,6 @@ if (process.argv.includes("--mcp")) {
console.warn = stderrConsole.warn.bind(stderrConsole);
}
// Electron persists secrets (JWT_SECRET, API_KEY_SECRET, STORAGE_ENCRYPTION_KEY) to
// `<DATA_DIR>/server.env` (electron/main.js), never `.env`. Migrating an existing
// install (storage.sqlite + server.env) to the CLI left those secrets undiscoverable —
// the CLI only ever looked for `.env`, so the STORAGE_ENCRYPTION_KEY needed to decrypt
// the migrated database was silently dropped (#7302). One-time, one-directory migration:
// if `<dataDir>/.env` is absent but `<dataDir>/server.env` is present, copy it to `.env`
// so it flows through the normal env-loading path below. Never overwrites an existing
// `.env` — an explicit `.env` always wins over a legacy `server.env`.
function migrateElectronServerEnv(dataDir) {
try {
const envPath = join(dataDir, ".env");
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`
);
} catch {
// Ignore errors migrating server.env — fall back to normal env loading below.
}
}
function loadEnvFile() {
const envPaths = [];
const loadedEnvPaths = [];
@@ -100,8 +50,6 @@ function loadEnvFile() {
envPaths.push(envPath);
};
migrateElectronServerEnv(process.env.DATA_DIR || getDefaultDataDir());
if (process.env.DATA_DIR) {
addEnvPath(join(process.env.DATA_DIR, ".env"));
}

View File

@@ -1 +0,0 @@
- **feat(sse):** every completion response now carries an `X-OmniRoute-Decision: strategy=<name>; provider=<alias>; latency_ms=<n>` header exposing the routing decision — `<name>` is the combo strategy (`priority`, `weighted`, `fusion`, etc.) or `single` for a non-combo request — for client-side debugging/analytics without server log access (#6022 — thanks @chirag127).

View File

@@ -1 +0,0 @@
- **feat(ws):** the live-dashboard WebSocket server now auto-starts in-process (via `instrumentation-node.ts`) across every deployment mode — dev, production, Docker, Electron — with no separate sidecar script; the default WS port moved from 20129 to **20132** to avoid colliding with `API_PORT` in split-port setups, the deprecated `OMNIROUTE_DISABLE_LIVE_WS` env was consolidated into `OMNIROUTE_ENABLE_LIVE_WS` (default enabled), and the WS path is now derived from `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`'s pathname (`/live-ws` fallback) (#6072 — thanks @ianriizky).

View File

@@ -1 +0,0 @@
- feat(sse): configurable per-model upstream header-response timeout override, precedence model > provider > global; applied to codex reasoning-heavy tiers (gpt-5.5-high/xhigh, gpt-5.6-\*-high/xhigh) (#6354)

View File

@@ -1 +0,0 @@
- **feat(dashboard):** Replace free-text model inputs in the Routing (web search route), Combo Defaults (handoff model), and Background Degradation tabs with a `hidePaidModels`-aware `ModelSelectField`, add a fail-open "paid-only pattern" warning to the per-model routing rule pattern field, and reject paid-only model targets at save time on `PATCH /api/settings`, `PATCH /api/settings/combo-defaults`, and `PUT /api/settings/background-degradation` when `hidePaidModels` is on ([#6540](https://github.com/diegosouzapw/OmniRoute/issues/6540))

View File

@@ -1 +0,0 @@
- **feat(compression):** new **omniglyph** engine (context-as-image) — renders system prompt, tool docs, and dense history as compact PNG pages the model reads instead of text (~10× fewer tokens on the converted block; 5970% end-to-end measured). Works stacked with RTK/Caveman (`stackPriority: 90`) or standalone (`mode: omniglyph`); restricted to Claude Fable 5 over the direct Anthropic route, fail-closed gates with `skip:<reason>` techniques, preview (`stable: false`, off by default) (#6556). Dependency bumped to `omniglyph@^1.0.2` for upstream ReDoS fixes (#6661).

View File

@@ -1 +0,0 @@
- **feat(sse):** rate-limit request queue admission control — `resilienceSettings.requestQueue.maxQueueDepth` (default `0` = disabled, opt-in 0100000) fast-rejects a request with a typed `RATE_LIMIT_QUEUE_FULL` error once the local per-provider+connection queue already holds `maxQueueDepth` requests, instead of growing the queue unboundedly; the factory default for `requestQueue.maxWaitMs` (how long a request may wait before being dropped) also fell from 120s to 15s so a saturated queue fails fast (#6593 — thanks @chirag127).

View File

@@ -1 +0,0 @@
- **feat(sandbox):** the skill sandbox gained a container-provider abstraction that auto-detects and uses the best native runtime per host — Apple Container (macOS 26+), WSL container (`wslc.exe`), OrbStack, Podman — instead of hardcoding `docker run`, removing the Docker Desktop requirement on macOS/Windows (#6611 — thanks @KooshaPari).

View File

@@ -1 +0,0 @@
- feat(oauth): accept the full ChatGPT session JSON (not just a bare access token) when pasting Codex credentials manually or via `POST /api/oauth/codex/import-token` (#6636)

View File

@@ -1 +0,0 @@
- feat(sse): add 5 no-key g4f.space gateway providers — `g4f-groq`, `g4f-gemini`, `g4f-pollinations`, `g4f-ollama`, `g4f-nvidia` — a free, no-signup reverse proxy (gpt4free project) fronting Groq, Gemini, Pollinations, Ollama, and NVIDIA NIM, rate-limited to 5 req/min per IP (#6650 — thanks @chirag127).

View File

@@ -1 +0,0 @@
- feat(sse): add DeepInfra as a video-generation provider via its native synchronous inference endpoint (#6653)

View File

@@ -1 +0,0 @@
- feat(providers): add Freepik (Magnific Mystic) API-key image generation provider — async submit/poll flow with realism/fluid/zen/flexible/super_real/editorial_portraits models (#6654)

View File

@@ -1 +0,0 @@
- feat(providers): add Rev AI speech-to-text provider with async job upload/poll/transcript flow (#6655)

View File

@@ -1 +0,0 @@
- **feat(providers):** add **Segmind** as an image + video generation provider — `x-api-key` auth against `POST https://api.segmind.com/v1/{model}`, with a curated starter model list (Flux, Stable Diffusion XL/3.5, Kandinsky for image; Wan, Hunyuan, LTX, Kling for video) (#6656).

View File

@@ -1 +0,0 @@
- feat(providers): add Gladia as an async speech-to-text provider (#6657)

View File

@@ -1 +0,0 @@
- feat(video): add Novita AI as a video-generation provider (Wan/Kling async submit-poll) (#6658)

View File

@@ -1 +0,0 @@
- **feat(providers):** add Speechmatics as an STT provider — async batch transcription (Enhanced operating point), 8 hours/month free tier, no credit card required. Streaming (real-time) mode is out of scope for v1. (#6659)

View File

@@ -1 +0,0 @@
- feat(providers): add Mixedbread AI as an embeddings provider (`mxbai-embed-large-v1`, `mxbai-embed-2d-large-v1`, free tier) (#6660)

View File

@@ -1 +0,0 @@
- **feat(providers):** add Felo (felo.ai) as a free, no-signup, no-API-key chat/search-agent aggregator provider (`felo-web`) — joins the existing `-web` family (DuckDuckGo AI Chat, Blackbox, etc). Five models (`felo-chat`, `felo-search`, `felo-scholar`, `felo-social`, `felo-document`) map to Felo's search categories; the executor opens a search thread then translates Felo's bespoke SSE stream into OpenAI-compatible chunks (#6666).

View File

@@ -1 +0,0 @@
- **feat(providers):** add gTTS (Google Translate TTS) as a free, no-signup audio-speech provider — routes through Google's current `batchexecute` RPC endpoint (the previously proposed `translate_tts` endpoint is deprecated), splitting input at the 100-char-per-request limit. (#6667)

View File

@@ -1 +0,0 @@
- **feat(sse):** add EdgeTTS (Microsoft Edge "Read Aloud") as a free, no-API-key `audio-tts` provider — the first WebSocket-transport speech provider, with per-client-IP rate limiting. (#6668)

View File

@@ -1 +0,0 @@
- feat(providers): add FreeTheAi as an OpenAI-compatible gateway provider with a free Discord-signup tier (#6670)

View File

@@ -1 +0,0 @@
- feat(sse): add Microsoft Designer as an unofficial web-session image provider, reverse-engineered submit-then-poll DallE.ashx flow (#6672)

View File

@@ -1 +0,0 @@
- **feat(providers):** add Hailuo Web (`hailuo-web`) — a free, `_token`-based web-cookie chat provider for the MiniMax consumer chat product at hailuo.ai, ported from the g4f reference implementation (MD5-chain request signing, custom `event:`/`data:` SSE parsing). Distinct from the existing paid API-key `minimax`/`minimax-cn` providers. (#6673)

View File

@@ -1 +0,0 @@
- **feat(cli):** new `omniroute auth export` command dumps DECRYPTED provider credentials (`apiKey`/`accessToken`/`refreshToken`/`idToken`) for one connection (`--id <id>`) or all connections, as `json` or `env` (`--format`), local-only and gated behind `--force` — no DB access happens without it, a stderr warning banner prints before any plaintext, `--out <file>` writes with `0600` permissions, and per-field decrypt failures surface as a `<field>DecryptFailed` boolean instead of aborting the export or leaking the caught error text (#6683)

View File

@@ -1 +0,0 @@
- **feat(mitm):** the AgentBridge static MITM server (`server.cjs`) can now issue a per-host TLS leaf from a persisted local root CA (`src/mitm/cert/rootCa.ts`, reusing the CA/leaf crypto already proven for TPROXY in `tproxy/dynamicCert.ts`) instead of a single static self-signed leaf scoped only to the 4 antigravity hosts — a fresh install covers the full `MITM_TOOL_HOSTS` set automatically; an install that already trusted the old leaf keeps using it until the operator opts in via `MITM_ROOT_CA_ENABLED=true` (`src/mitm/cert/migration.ts`), so no existing install is silently upgraded to the more powerful any-host-signing CA trust model (#6684).

View File

@@ -1 +0,0 @@
- **fix(sse):** skip `thinkingConfig` for Gemma models on the OpenAI→Gemini path so OpenAI-shape clients no longer get a 400 from Vertex. (thanks @chy1211)

View File

@@ -1 +0,0 @@
- **feat(xai):** route xAI clients to Grok's native `/v1/responses` endpoint instead of the chat-completions bridge. (thanks @ryanngit)

View File

@@ -1 +0,0 @@
- **feat(models):** add a Settings → AI "Model Overrides" UI plus `/api/model-capability-overrides` CRUD and a `model_capability_overrides` table, letting operators set a manual max-output-token override per provider/model (#6727 — thanks @xz-dev).

View File

@@ -1 +0,0 @@
- **feat(api):** add `Vary: Accept-Encoding` to token-authenticated `/v1*`/`/v1beta*` responses so downstream caches distinguish compressed vs uncompressed variants (RFC 9110 §12.5.5). (thanks @chirag127)

View File

@@ -1 +0,0 @@
- feat(sse): add Notion AI Web (Unofficial/Experimental) cookie-session provider (#6758)

View File

@@ -1 +0,0 @@
- **feat(dashboard):** add per-routing-combo compression-mode override to the Compression Combos page under Context & Cache, alongside the existing combo-card quick override. (#6760)

View File

@@ -1 +0,0 @@
- **feat(resilience):** operator-configurable account rotation policy — a new `rotationConfig` layer lets operators tune how connections rotate on failure, wired into `accountFallback` (#6763 — thanks @artickc).

View File

@@ -1 +0,0 @@
- **feat(sse):** preserve `tools`/`tool_choice` for tool-bearing requests through fusion combos — bypass panel synthesis and route straight to the judge with tools intact (#6771 — thanks @chirag127).

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