mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-23 07:12:29 +03:00
Compare commits
1 Commits
fix/cli-co
...
fix/13381-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52562e94ac |
205
.env.example
205
.env.example
@@ -112,9 +112,8 @@ DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
# Namespace prefix for ALL OmniRoute Redis keys (rate limiter + auth cache +
|
||||
# quota store + warmup circuit breaker). Prevents key collisions when OmniRoute
|
||||
# shares a Redis instance with other apps (e.g. on 127.0.0.1:6379). Default when
|
||||
# unset: omniroute:
|
||||
# quota store). Prevents key collisions when OmniRoute shares a Redis instance
|
||||
# with other apps (e.g. on 127.0.0.1:6379). Default when unset: omniroute:
|
||||
# REDIS_KEY_PREFIX=omniroute:
|
||||
# Host interface docker-compose publishes the Redis sidecar on.
|
||||
# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT
|
||||
@@ -173,11 +172,6 @@ PORT=20128
|
||||
# stay consistent without relying on window.location.origin alone:
|
||||
# NEXT_PUBLIC_BASE_URL=https://host/omniroute
|
||||
#
|
||||
# Client-side fallback port for display URLs when no origin is known (SSR/tests):
|
||||
# read before PORT so a browser bundle built with a different public port still
|
||||
# renders the right http://localhost:<port> links (src/shared/hooks/useDisplayBaseUrl.ts).
|
||||
# NEXT_PUBLIC_PORT=20128
|
||||
#
|
||||
# Explicit path probed by the container health check. Unset, the probe derives it
|
||||
# from OMNIROUTE_BASE_PATH; setting it opts back into the deep monitoring endpoint.
|
||||
# Used by: scripts/dev/healthcheck.mjs
|
||||
@@ -293,9 +287,9 @@ OMNIROUTE_USE_TURBOPACK=1
|
||||
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
|
||||
|
||||
# Interval (ms) for the background credential health check scheduler.
|
||||
# Default: 3600000 (60 minutes). Minimum: 10000 (10 seconds).
|
||||
# Default: 300000 (5 minutes). Minimum: 10000 (10 seconds).
|
||||
# Used by: open-sse/config/constants.ts, src/lib/credentialHealth/scheduler.ts
|
||||
# CREDENTIAL_HEALTH_CHECK_INTERVAL=3600000
|
||||
# CREDENTIAL_HEALTH_CHECK_INTERVAL=300000
|
||||
|
||||
# TTL (ms) for cached credential health status.
|
||||
# Default: 300000 (5 minutes).
|
||||
@@ -546,48 +540,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable.
|
||||
# OUTBOUND_SSRF_GUARD_ENABLED=true
|
||||
|
||||
# ── Self-hosted unified OpenAI-compatible entry (RIC-738, D4) ────────────────────
|
||||
# When set, /v1/chat/completions diverts to the self-hosted provider adapters
|
||||
# (open-sse/services/selfHostedEntry.ts) instead of the cloud pipeline. YAML inline
|
||||
# (example) — or point OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE at a YAML file. Secrets
|
||||
# are runtime-only, never logged. While ANY of these is set, the entry is active;
|
||||
# config present but unparseable returns a 500 (never silently falls through).
|
||||
# OMNIROUTE_SELF_HOSTED_PROVIDERS='
|
||||
# providers:
|
||||
# - id: local
|
||||
# kind: openai
|
||||
# baseUrl: http://127.0.0.1:11434/v1
|
||||
# model: llama3
|
||||
# - id: claude
|
||||
# kind: anthropic
|
||||
# baseUrl: http://127.0.0.1:8080
|
||||
# model: claude-sonnet
|
||||
# '
|
||||
# OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE=/etc/omniroute/providers.yaml
|
||||
# Optional shared API key for the unified entry (D5 reserved). When set, require
|
||||
# `Authorization: Bearer <key>`; empty = open loopback/trusted-network route.
|
||||
# OMNIROUTE_SELF_HOSTED_API_KEY=
|
||||
|
||||
# ── Deterministic routing strategies (M2 / RIC-740, D3 可审计路由) ─────────────
|
||||
# Optional `strategy:` block — either inline in the providers document above, or a
|
||||
# standalone document via these env vars. One rule per line; every decision is
|
||||
# explainable via the `x-omniroute-route-decision` response header. No ML/predict.
|
||||
# Malformed strategy config returns a 500 (never silently becomes a no-op).
|
||||
# Example (inline, same shape as `strategy:` inside the providers YAML):
|
||||
# OMNIROUTE_SELF_HOSTED_STRATEGY='
|
||||
# blacklist: []
|
||||
# whitelist: [cheap, fast, premium]
|
||||
# costPriority: true
|
||||
# latencyAware:
|
||||
# enabled: true
|
||||
# cooldown:
|
||||
# consecutiveFailures: 2
|
||||
# cooldownMs: 30000
|
||||
# fallbackChain: [cheap, fast, premium]
|
||||
# '
|
||||
# OMNIROUTE_SELF_HOSTED_STRATEGY_FILE=/etc/omniroute/strategy.yaml
|
||||
# See docs/routing/DETERMINISTIC_ROUTING.md for the full strategy surface.
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 5. INPUT SANITIZATION & PII PROTECTION (FASE-01)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -669,15 +621,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# Validated to >= 1, clamped to <= 32. | Default: 3
|
||||
# COMBO_CONCURRENCY_PER_MODEL=3
|
||||
|
||||
# Disable conversation-history tracking (#13150).
|
||||
# Used by: open-sse/services/conversationTracker.ts. resolveConversationId()
|
||||
# returns an untracked result before it reads SQLite or parses message history,
|
||||
# and the switch also covers client-supplied session IDs. Routing sessions are
|
||||
# unaffected and existing records are not deleted. Use it when the dashboard's
|
||||
# conversation view is unused and the turn table has grown large.
|
||||
# Set to 1 to disable. | Default: unset (tracking enabled)
|
||||
# OMNIROUTE_DISABLE_CONVERSATION_TRACKING=1
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 7. URLS & CLOUD SYNC
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -768,26 +711,14 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
# OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of
|
||||
# opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers.
|
||||
# When your clients don't already send them, set this to synthesize the CLI headers
|
||||
# (User-Agent, x-opencode-client, x-opencode-project, canonical request/session ids) on
|
||||
# absent keys. ON by default — a client value always wins, these only fill gaps.
|
||||
# (User-Agent, x-opencode-client, x-opencode-project, fresh request/session UUIDs) on
|
||||
# absent keys. OFF by default — forward-only is safer when clients already send them.
|
||||
# Values are overridable via OPENCODE_GO_USER_AGENT / OPENCODE_USER_AGENT / OPENCODE_CLIENT /
|
||||
# OPENCODE_PROJECT (defaults: opencode/1.18.31 / desktop / global).
|
||||
# OPENCODE_PROJECT (defaults: opencode-cli/1.0.0 / cli / default).
|
||||
#OPENCODE_SYNTHESIZE_CLI_HEADERS=true
|
||||
#OPENCODE_USER_AGENT=opencode/1.18.31
|
||||
#OPENCODE_CLIENT=desktop
|
||||
#OPENCODE_PROJECT=global
|
||||
|
||||
# Keyless OpenCode models are answered only when the request declares a non-empty tool
|
||||
# list, and the upstream inspects which names it carries. OmniRoute reuses the list a
|
||||
# request of the same conversation was last seen getting through, so a request that
|
||||
# carries none — a title or a summary — goes out with the list its own client already
|
||||
# declared. Set to off to stop adjusting request bodies entirely; headers are unaffected.
|
||||
#OPENCODE_FREE_TIER_REQUEST_CONTRACT=off
|
||||
|
||||
# Tool names to declare when nothing has been observed yet for a model, comma-separated.
|
||||
# Empty falls back to a single placeholder the model is told not to call. Only useful on
|
||||
# an install where no client sends tools, since there is then nothing to learn from.
|
||||
#OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS=glob,grep,read
|
||||
#OPENCODE_USER_AGENT=opencode-cli/1.0.0
|
||||
#OPENCODE_CLIENT=cli
|
||||
#OPENCODE_PROJECT=default
|
||||
|
||||
# Ollama Cloud quota scraping. Prefer configuring this per connection in
|
||||
# Dashboard → Providers → Ollama Cloud. The cookie is sensitive.
|
||||
@@ -1161,11 +1092,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
|
||||
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
|
||||
|
||||
# Character cap for Lite proactive tool-result truncation when lite.maxToolLength
|
||||
# is unset. Range 256-1000000. Dashboard setting wins over this env.
|
||||
# Used by: open-sse/services/compression/lite.ts. Default: 2000.
|
||||
#OMNIROUTE_LITE_MAX_TOOL_LENGTH=2000
|
||||
|
||||
# Maximum concurrent synchronous compression workers. Excess jobs wait FIFO.
|
||||
# Used by: open-sse/services/compression/compressionWorkerPool.ts. Default: 2.
|
||||
#OMNI_COMPRESSION_WORKERS=2
|
||||
@@ -1222,37 +1148,25 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: src/lib/db/core.ts::getDbHealthCheckIntervalMs().
|
||||
#OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS=21600000
|
||||
|
||||
# Removed: periodic live wal_checkpoint(TRUNCATE) could SIGBUS the process (issue
|
||||
# #13973). The variable is inert: a positive value logs a one-time deprecation warning,
|
||||
# while 0 or unset stays silent. The WAL is kept small
|
||||
# by the PASSIVE scheduler below and truncated by the shutdown checkpoint.
|
||||
# WAL truncate cadence override (ms). Set to 0 to disable. Default: 21600000 (6h).
|
||||
# Used by: src/lib/db/core.ts::getWalTruncateIntervalMs().
|
||||
#OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS=21600000
|
||||
|
||||
# Frequent wal_checkpoint(PASSIVE) cadence (ms). Set to 0 to disable. Default: 300000 (5m).
|
||||
# Used by: src/lib/db/walMaintenance.ts.
|
||||
#OMNIROUTE_WAL_PASSIVE_INTERVAL_MS=300000
|
||||
|
||||
# WAL size (MB) above which a PASSIVE tick runs wal_checkpoint(RESTART) so the
|
||||
# WAL starts over without rewriting the mapped wal-index. Default: 256.
|
||||
# WAL size (MB) above which a PASSIVE tick escalates to wal_checkpoint(TRUNCATE). Default: 256.
|
||||
# Used by: src/lib/db/walMaintenance.ts.
|
||||
#OMNIROUTE_WAL_GUARD_MAX_MB=256
|
||||
|
||||
# Minimum rows a cleanup must delete before the post-cleanup VACUUM runs. Default: 1000.
|
||||
# 0 always vacuums when rows were freed. Used by: src/lib/db/cleanup.ts.
|
||||
#OMNIROUTE_VACUUM_MIN_DELETED_ROWS=1000
|
||||
|
||||
# Explicit path to sql-wasm.wasm for the sql.js fallback adapter. Default: auto-detect.
|
||||
# Used by: src/lib/db/adapters/sqljsAdapter.ts.
|
||||
#OMNIROUTE_SQLJS_WASM_PATH=
|
||||
# Days a terminal (completed/failed/cancelled/expired) Batch API job's checkpoints,
|
||||
# referenced files, and row are kept by the automatic cleanup sweep. Default: 30
|
||||
# (matches OpenAI's own Batch API output retention window). Only takes effect once
|
||||
# BATCH_AND_FILE_AUTO_CLEANUP_ENABLED is turned on.
|
||||
# Used by: src/lib/db/cleanup.ts::getBatchRetentionDays().
|
||||
#OMNIROUTE_BATCH_RETENTION_DAYS=30
|
||||
|
||||
# Let the automatic cleanup sweep delete terminal Batch API jobs (and their
|
||||
# checkpoints) past OMNIROUTE_BATCH_RETENTION_DAYS, and clear the content of
|
||||
# uploaded files past their own expires_at. Off by default: every existing
|
||||
# install keeps this data exactly as before until an operator opts in.
|
||||
# Used by: src/lib/db/cleanup.ts (feature flag; see docs/reference/FEATURE_FLAGS.md).
|
||||
#BATCH_AND_FILE_AUTO_CLEANUP_ENABLED=false
|
||||
|
||||
# Skip the Redis-backed auth cache used by API key lookups (forces DB reads).
|
||||
# Used by: src/lib/db/apiKeys.ts. Set to 1 to disable. Default: enabled.
|
||||
@@ -1409,10 +1323,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# VISION_BRIDGE_BASE_URL=
|
||||
# VISION_BRIDGE_API_KEY=
|
||||
|
||||
# How long a "no usable vision candidate" outcome is remembered, in ms.
|
||||
# Invalid or negative values fall back to the default; 0 disables the negative cache.
|
||||
# OMNIROUTE_VISION_BRIDGE_NEGATIVE_CACHE_MS=30000
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1452,7 +1362,7 @@ CLAUDE_USER_AGENT="claude-cli/2.1.258 (external, cli)"
|
||||
# forward the original names verbatim (debugging only).
|
||||
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
|
||||
# Optional override; leave unset to follow the shared Codex client version.
|
||||
# CODEX_USER_AGENT="codex-cli/0.155.0 (Windows 10.0.26200; x64)"
|
||||
# CODEX_USER_AGENT="codex-cli/0.153.4 (Windows 10.0.26200; x64)"
|
||||
GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0"
|
||||
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
|
||||
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
|
||||
@@ -1472,7 +1382,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
|
||||
# Override Codex client version sent in headers independently of the
|
||||
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
|
||||
# CODEX_CLIENT_VERSION=0.155.0
|
||||
# CODEX_CLIENT_VERSION=0.153.4
|
||||
#
|
||||
# Override the advertised Claude Code client version independently of
|
||||
# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this
|
||||
@@ -1483,13 +1393,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# Override the advertised GitHub Copilot CLI version independently of
|
||||
# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts.
|
||||
# GITHUB_COPILOT_CLI_VERSION=1.0.82
|
||||
#
|
||||
# Pin the `copilot-integration-id` header sent to standard GitHub Copilot,
|
||||
# overriding the default copilot-developer-cli identity (and disabling the
|
||||
# automatic 403-identity fallback to copilot-chat). Set this only if your
|
||||
# Copilot account/org requires a specific integration id. Used by:
|
||||
# open-sse/config/providerHeaderProfiles.ts, open-sse/executors/copilotIdentityFallback.ts.
|
||||
# COPILOT_INTEGRATION_ID=copilot-chat
|
||||
|
||||
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
|
||||
# from the Codex Responses stream. These frames break the OpenAI SDK's
|
||||
@@ -1597,7 +1500,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
#
|
||||
# Hierarchy: REQUEST_TIMEOUT_MS acts as a global override.
|
||||
# If set, it becomes the default for FETCH_TIMEOUT_MS, STREAM_IDLE_TIMEOUT_MS,
|
||||
# and STREAM_READINESS_TIMEOUT_MS. STREAM_ACTIVE_TIMEOUT_MS is independent.
|
||||
# and STREAM_READINESS_TIMEOUT_MS.
|
||||
# The fine-grained variables below override their respective defaults only when set.
|
||||
|
||||
# ── Global shortcut ──
|
||||
@@ -1618,18 +1521,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# # caller's deadline; on expiry the request retries
|
||||
# # once on a fresh no-keep-alive socket. 0 disables
|
||||
# # the bound (default: 30000 = 30s).
|
||||
# OMNIROUTE_DIRECT_RESPONSE_RETRY_TIMEOUT_MS=600000 # Ceiling (ms) for the fresh-socket
|
||||
# # RETRY attempt above (#13703). Only applies when
|
||||
# # the caller already attached its own deadline
|
||||
# # signal (the resolved connection/model/provider/
|
||||
# # FETCH_TIMEOUT_MS cascade) — that signal is the
|
||||
# # real bound and fires first in the intended path,
|
||||
# # so this is a generous backstop rather than a flat
|
||||
# # cap: without it the retry reused the same short
|
||||
# # OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS window as the
|
||||
# # pooled attempt and 504'd healthy slow-TTFB
|
||||
# # reasoning models. Never allowed below the flat
|
||||
# # floor above (default: 600000 = 10 min).
|
||||
|
||||
# Default timeout (ms) for src/shared/utils/fetchTimeout.ts. Acts as the
|
||||
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
|
||||
@@ -1791,8 +1682,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# ── Stream idle detection ──
|
||||
# STREAM_IDLE_TIMEOUT_MS=600000 # Max silence between SSE chunks (default: 600000)
|
||||
# # Extended-thinking models rarely pause >90s.
|
||||
# STREAM_ACTIVE_TIMEOUT_MS=1260000 # Max total active SSE lifetime (default: 21 min = the largest registered model timeoutMs + 1 min; 0 disables)
|
||||
# # Independent of REQUEST_TIMEOUT_MS and byte activity.
|
||||
# STREAM_READINESS_TIMEOUT_MS=80000 # Time to receive the first non-ping SSE event
|
||||
# STREAM_READINESS_MAX_TIMEOUT_MS=180000 # Cap for adaptive first-event extensions
|
||||
# # (large/tool-heavy/high-reasoning requests).
|
||||
@@ -1810,8 +1699,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
|
||||
# TLS_FIRST_BYTE_WATCHDOG_MS=10000 # #12656: bounds time-to-first-byte on the wreq body (0 disables)
|
||||
# OPENCODE_RESPONSES_STALL_ROTATION=false # #13484 feature flag (Settings → Feature Flags wins): rotate once when a streamed Responses reply stalls before its first byte
|
||||
# OPENCODE_PARK_AND_RESUME=false # #13924 feature flag (Settings → Feature Flags wins): park the request with a heartbeat after repeated transient 429s, then replay one capped leg of up to 3 accounts
|
||||
#OPENCODE_POOL_STRAIN_MARKER_PATH=/tmp/opencode-pool-strain.json # #13924: pool-strain marker path (JSON {since, reason, ttl_s}); fresh marker parks without recounting
|
||||
# RESPONSES_FIRST_BYTE_TIMEOUT_MS=15000 # #13484: OpenCode Responses first-byte window, only used when the OPENCODE_RESPONSES_STALL_ROTATION flag is on (0 disables)
|
||||
|
||||
# ── API Bridge (/v1 proxy server) ──
|
||||
@@ -1901,8 +1788,8 @@ APP_LOG_TO_FILE=true
|
||||
# bodies is retained in the database.
|
||||
# Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload()
|
||||
# CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB)
|
||||
# CHAT_LOG_ARRAY_TAIL_ITEMS=1000 # Number of array items retained from tail (default: 1000)
|
||||
# CHAT_LOG_MAX_DEPTH=20 # Max nesting depth before truncation (default: 20)
|
||||
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
|
||||
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
|
||||
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
|
||||
# CHAT_LOG_MAX_BODY_KB=1024 # Whole request/response body size before it's replaced by a bare
|
||||
# {_truncated, messageCount, ...} summary instead of the full clone
|
||||
@@ -2276,13 +2163,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
|
||||
# MUX_SERVICE_PORT=8322
|
||||
|
||||
# ── open-wa embedded service ──
|
||||
# Override the port where the embedded open-wa (WhatsApp Web automation)
|
||||
# daemon listens. Always bound to 127.0.0.1 — never configurable to 0.0.0.0.
|
||||
# Rarely needed — defaults to 8323.
|
||||
# Used by: src/lib/services/bootstrap.ts
|
||||
# OPENWA_SERVICE_PORT=8323
|
||||
|
||||
# ── Dario embedded service ──
|
||||
# Override the host/port the embedded Dario (Claude Code subscription proxy)
|
||||
# daemon binds to and is reached at. Always bound to 127.0.0.1 — never
|
||||
@@ -2336,11 +2216,6 @@ APP_LOG_TO_FILE=true
|
||||
# PROXY_HEALTH_ENABLED=true
|
||||
# Sweep interval in ms (minimum 60000). Default: 600000 (10min).
|
||||
# PROXY_HEALTH_INTERVAL_MS=600000
|
||||
|
||||
# Background recovery-pass interval in ms: how often the scheduler re-probes proxies it
|
||||
# previously marked unhealthy, so a proxy that comes back is picked up without a restart.
|
||||
# Values below 60000 fall back to the default.
|
||||
# PROXY_HEALTH_RECOVERY_INTERVAL_MS=600000
|
||||
# Reachability probe target for the scheduler and the auto-test endpoint.
|
||||
# Point it at an internal/self-hosted URL to avoid the public default.
|
||||
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
|
||||
@@ -2499,22 +2374,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/executors/cursor.ts.
|
||||
# CURSOR_TOOL_DIRECTIVE=1
|
||||
|
||||
# Operator-defined system prompt text appended to the system message AFTER
|
||||
# translation (post-translation injection), so it reaches codex/Responses and
|
||||
# /v1/messages paths. Also used as the directive prefix stripped from echoed
|
||||
# system preamble blocks. Leave unset to disable.
|
||||
# Used by: open-sse/translator/request/claude-to-openai.ts, open-sse/translator/response/openai-to-claude.ts.
|
||||
# OMNIROUTE_SYSTEM_INSTRUCTION_APPEND=
|
||||
|
||||
# Set to "1" to also strip echoed system-prompt PREAMBLE blocks
|
||||
# (<analysis>/<system-reminder>/<summary> blocks, prose reproductions of the skill
|
||||
# section) from the start of an openai->claude stream. OFF by default: it recognises
|
||||
# constructs by English-prose heuristics and DOES mutate the response payload, so a
|
||||
# reply that genuinely opens with such a section would lose it. Turn it on only when
|
||||
# you actually hit the system-echo leak.
|
||||
# Used by: open-sse/translator/response/openai-to-claude.ts, open-sse/utils/directivePreambleStripper.ts.
|
||||
# OMNIROUTE_STRIP_SYSTEM_PREAMBLE=0
|
||||
|
||||
# Per-image fetch timeout (ms) for remote image_url vision input. Default: 15000.
|
||||
# Used by: open-sse/utils/cursorImages.ts.
|
||||
# CURSOR_IMAGE_FETCH_TIMEOUT_MS=15000
|
||||
@@ -2711,16 +2570,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
|
||||
|
||||
# ── Antigravity MITM bridge (bin/antigravity-bridge.mjs) ──
|
||||
# Local HTTPS listener that fronts the Antigravity IDE and forwards to the router.
|
||||
# BRIDGE_PORT: port the bridge listens on. Defaults to 20129.
|
||||
# ROUTER_URL: where it forwards /v1/antigravity traffic. Defaults to the local router.
|
||||
# CERT_DIR: directory holding server.key/server.crt for the bridge's TLS listener.
|
||||
# Defaults to ~/.omniroute/mitm (the MITM CA directory).
|
||||
# BRIDGE_PORT=20129
|
||||
# ROUTER_URL=http://127.0.0.1:20128/v1/antigravity
|
||||
# CERT_DIR=~/.omniroute/mitm
|
||||
# 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.
|
||||
@@ -2757,16 +2606,6 @@ APP_LOG_TO_FILE=true
|
||||
# When enabled, the node authenticates with the API key stored on its connection.
|
||||
# AUDIO_REMOTE_PROVIDER_NODES=false
|
||||
|
||||
# Used by: src/app/api/v1/_shared/rerankProviderNodes.ts — lets POST /v1/rerank (and
|
||||
# the memory engine's loopback rerank step) use an OpenAI-compatible provider node
|
||||
# hosted outside localhost, e.g. a LAN box or Tailscale peer running TEI/Infinity/vLLM.
|
||||
# OFF by default: routing to a remote host changes egress identity, so it must be an
|
||||
# explicit operator decision. Loopback/private nodes (localhost, 127.0.0.1,
|
||||
# 172.16-31.x) are always allowed and unaffected by this flag. Remote nodes must also
|
||||
# pass the provider outbound URL policy (see OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS);
|
||||
# cloud-metadata hosts are never routed to.
|
||||
# RERANK_REMOTE_PROVIDER_NODES=false
|
||||
|
||||
# ── 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).
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# OmniRoute — Self-Host env (minimal, zero-fee self-host)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# cp .env.selfhost.example .env
|
||||
# Edit only the two lines marked `# EDIT ME`. Everything else has a sane
|
||||
# default. No secrets are baked in — OmniRoute never ships credentials.
|
||||
#
|
||||
# Full variable reference: docs/guides/DOCKER_GUIDE.md and .env.example
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Ports (host-side) ──────────────────────────────────────────────────
|
||||
# Dashboard + API + Live-WS. Already match the image defaults.
|
||||
DASHBOARD_PORT=20128
|
||||
API_PORT=20129
|
||||
LIVE_WS_PORT=20132
|
||||
|
||||
# ── Bind address ───────────────────────────────────────────────────────
|
||||
# 127.0.0.1 = loopback only (safe with REQUIRE_API_KEY=false, the default).
|
||||
# Set to 0.0.0.0 ONLY when REQUIRE_API_KEY=true OR a reverse proxy
|
||||
# enforces auth upstream. Exposing an unauthenticated /v1 proxy on the
|
||||
# LAN/WAN lets anyone burn your provider quotas. # EDIT ME if you must.
|
||||
APP_BIND_HOST=127.0.0.1
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────
|
||||
# false = the dashboard and /v1 proxy are open to APP_BIND_HOST's network.
|
||||
# true = every request needs an API key / dashboard login. The dashboard
|
||||
# auto-creates INITIAL_PASSWORD on first boot (read it from the logs:
|
||||
# `docker logs omniroute | grep -i password`). # EDIT ME — set true.
|
||||
REQUIRE_API_KEY=false
|
||||
# INITIAL_PASSWORD= # uncomment to pre-seed the dashboard password
|
||||
|
||||
# ── Memory ceiling (V8 old-space) ──────────────────────────────────────
|
||||
# 1024 = dashboard + light chat. Coding agents (long POST /v1/responses
|
||||
# bodies) need more — see SELF_HOST_GUIDE.md "sizing". 2048 is a safe
|
||||
# default for a single user who runs Claude Code / Codex through it.
|
||||
OMNIROUTE_MEMORY_MB=2048
|
||||
|
||||
# ── Browser-facing origin (optional) ───────────────────────────────────
|
||||
# Set ONLY if you expose OmniRoute behind a domain via a reverse proxy.
|
||||
# NEXT_PUBLIC_BASE_URL=https://your-domain.example.com
|
||||
# BASE_URL=http://omniroute:20128
|
||||
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior in OmniRoute
|
||||
title: "fix(): "
|
||||
title: "[BUG] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -8,8 +8,6 @@ body:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug. Please fill out the sections below so we can reproduce and fix the issue.
|
||||
|
||||
The title is prefilled as `fix(): ` to match the [Conventional Commits](https://github.com/diegosouzapw/OmniRoute/blob/main/CONTRIBUTING.md#commit-messages) convention — pick a scope from the list documented there (e.g. `providers`, `resilience`, `dashboard`, `api`).
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
4
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement for OmniRoute
|
||||
title: "feat(): "
|
||||
title: "[Feature] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -8,8 +8,6 @@ body:
|
||||
value: |
|
||||
Thanks for suggesting a feature! Please describe the problem you're trying to solve and how you'd like it to work.
|
||||
|
||||
The title is prefilled as `feat(): ` to match the [Conventional Commits](https://github.com/diegosouzapw/OmniRoute/blob/main/CONTRIBUTING.md#commit-messages) convention — pick a scope from the list documented there (e.g. `providers`, `resilience`, `dashboard`, `api`).
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
|
||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -144,16 +144,6 @@ jobs:
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:radar-sentinels
|
||||
- run: npm run check:tracked-artifacts
|
||||
- name: AI attribution in commit / PR metadata (Hard Rule #16)
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
printf '%s' "$PR_BODY" > "$RUNNER_TEMP/pr-body.md"
|
||||
npm run check:ai-attribution -- --range "$PR_BASE_SHA..$PR_HEAD_SHA" --pr-title "$PR_TITLE" --pr-body-file "$RUNNER_TEMP/pr-body.md"
|
||||
# A test parked in vitest.config.ts's exclude list does not run, and looks like
|
||||
# coverage to whoever reads the tree. 62 files accumulated behind a comment pointing
|
||||
# at #8618 — closed in August while the list grew to 62; 51 of them passed when
|
||||
@@ -463,11 +453,8 @@ jobs:
|
||||
# One FS inventory of src/app/api for both anti-hallucination directions.
|
||||
- name: API docs refs (openapi + prose → routes)
|
||||
run: npm run check:api-docs-refs
|
||||
# Blocking since the 2026-09 docs re-sync: a core doc edited without `npm run i18n:run
|
||||
# --files=<doc>` (or `--adopt` for a mechanical edit) leaves 65 stale mirrors behind;
|
||||
# the run only retranslates the `## ` sections whose text changed, so it is cheap.
|
||||
- name: i18n docs drift (sources changed since their translation)
|
||||
run: node scripts/i18n/check-translation-drift.mjs
|
||||
- name: i18n translation drift (warn)
|
||||
run: node scripts/i18n/check-translation-drift.mjs --warn
|
||||
|
||||
docs-lint:
|
||||
name: Docs Lint (prose — advisory)
|
||||
@@ -1123,7 +1110,7 @@ jobs:
|
||||
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
|
||||
timeout-minutes: 5
|
||||
continue-on-error: true
|
||||
uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -580,7 +580,7 @@ jobs:
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4.38.0
|
||||
uses: github/codeql-action/upload-sarif@v4.37.9
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
3
.github/workflows/quality.yml
vendored
3
.github/workflows/quality.yml
vendored
@@ -293,9 +293,6 @@ jobs:
|
||||
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
|
||||
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
|
||||
open-sse-typecheck
|
||||
# Hard Rule #16 — AI/bot attribution in PR commits, title or body (#14436). Reads the PR
|
||||
# from GITHUB_EVENT_PATH; no-op on non-PR events. ci.yml only runs on PRs to main.
|
||||
ai-attribution
|
||||
)
|
||||
ratchet_gates=(
|
||||
secrets vuln-ratchet workflows openapi-breaking
|
||||
|
||||
42
.github/workflows/release-acceptance.yml
vendored
42
.github/workflows/release-acceptance.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: Release acceptance
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["release/v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-acceptance-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
acceptance:
|
||||
name: Release acceptance
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Emit shadow acceptance report
|
||||
run: |
|
||||
node scripts/quality/validate-release-acceptance.mjs \
|
||||
--plan tests/fixtures/release-acceptance/plan-lint.json \
|
||||
--manifests tests/fixtures/release-acceptance/shadow-manifests \
|
||||
--out release-acceptance-report.json
|
||||
continue-on-error: true
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: release-acceptance-report
|
||||
path: release-acceptance-report.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -74,7 +74,6 @@ yarn-error.log*
|
||||
# Local gitleaks artifacts (do not commit)
|
||||
gitleaks-local.json
|
||||
!.env.example
|
||||
!.env.selfhost.example
|
||||
!.env.homolog.example
|
||||
!.env.devin-bridge.example
|
||||
# Provider API keys (never commit)
|
||||
@@ -219,7 +218,6 @@ scripts/i18n/_pending-keys.json
|
||||
|
||||
# PR Reviews and local feedback files
|
||||
pr_reviews*.json
|
||||
/review/
|
||||
|
||||
#hidden local data directories (never commit)
|
||||
.local-data/
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
# Hard Rule #16 — no AI/bot Co-Authored-By trailers or AI-generation footers in commit metadata.
|
||||
# Human co-authors stay. Incident record: #14436.
|
||||
node scripts/check/check-ai-attribution.mjs --message-file "$1"
|
||||
166348
.i18n-state.json
166348
.i18n-state.json
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,3 @@
|
||||
# wasm-bindgen glue + embedded WASM_BASE64. Prettier rewrites the generated JS
|
||||
# (quotes, wrapping) on any touch of this file; format tinycmsDomMocks.ts instead.
|
||||
open-sse/executors/tinycmsSigner.ts
|
||||
|
||||
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
|
||||
docs/reference/ENVIRONMENT.md
|
||||
|
||||
|
||||
@@ -5556,17 +5556,9 @@ export function createOmniRouteConfigHook(
|
||||
if (modelsFetchThrew && wantDiskCache && !warmSnapshot) {
|
||||
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
|
||||
if (snapshot && snapshot.rawModels.length > 0) {
|
||||
// Report snapshot age like the warm-startup path already does:
|
||||
// "stale" alone reads as a transient blip, so a week-old catalog
|
||||
// is indistinguishable from a five-minute-old one.
|
||||
const snapshotAge = snapshot.writtenAt;
|
||||
const snapshotAgeLabel =
|
||||
typeof snapshotAge === "number"
|
||||
? `${Math.round((Date.now() - snapshotAge) / 3_600_000)}h`
|
||||
: "unknown";
|
||||
logAt(
|
||||
"warn",
|
||||
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models, age ${snapshotAgeLabel})`
|
||||
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
|
||||
);
|
||||
localRawModels = snapshot.rawModels;
|
||||
localRawCombos = snapshot.rawCombos;
|
||||
|
||||
@@ -481,7 +481,10 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
];
|
||||
assert.ok(entry);
|
||||
const ids = Object.keys(entry.models).sort();
|
||||
assert.deepEqual(ids, ["claude-sonnet-4-6", "gemini-3-flash"]);
|
||||
assert.deepEqual(ids, [
|
||||
"claude-sonnet-4-6",
|
||||
"gemini-3-flash",
|
||||
]);
|
||||
assert.equal(entry.models["claude-tier"], undefined, "no combo entry");
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
@@ -1038,7 +1041,11 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag");
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained");
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
});
|
||||
|
||||
test("config: enrichment fetcher throws → soft-fail (warn + raw-id static catalog)", async () => {
|
||||
@@ -1061,7 +1068,11 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "static block still published on enrichment failure");
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id retained");
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
assert.equal(enrichmentFetcher.callCount(), 1);
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")),
|
||||
@@ -1259,7 +1270,10 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry.models["claude-sonnet-4-6"], "stale snapshot hydrated into static block");
|
||||
assert.ok(
|
||||
entry.models["claude-sonnet-4-6"],
|
||||
"stale snapshot hydrated into static block"
|
||||
);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6 (cached)",
|
||||
@@ -1267,95 +1281,14 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
);
|
||||
assert.equal(writes, 0, "disk write skipped when live fetch failed");
|
||||
assert.ok(
|
||||
logger.entries.some(
|
||||
(e) =>
|
||||
String(e[0]).includes("using stale disk cache") ||
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
logger.entries.some((e) =>
|
||||
String(e[0]).includes("using stale disk cache") ||
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
"disk-cache hydration breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// The stale-fallback branch (`modelsFetchThrew && wantDiskCache && !warmSnapshot`)
|
||||
// only runs when the warm-startup read found nothing — a snapshot can appear on
|
||||
// disk between that first read and the live fetch failing (e.g. another OC
|
||||
// process instance wrote one concurrently). A stateful reader simulates that:
|
||||
// empty on the warm-startup read, populated by the time the fallback re-reads.
|
||||
function emptyThenSnapshotReader(
|
||||
snapshot: Omit<
|
||||
Awaited<ReturnType<typeof import("../src/index.js").defaultDiskSnapshotReader>> & object,
|
||||
never
|
||||
>
|
||||
): typeof import("../src/index.js").defaultDiskSnapshotReader {
|
||||
let calls = 0;
|
||||
return (async () => {
|
||||
calls++;
|
||||
return calls === 1 ? undefined : snapshot;
|
||||
}) as typeof import("../src/index.js").defaultDiskSnapshotReader;
|
||||
}
|
||||
|
||||
test("config: stale-fallback warning reports the disk snapshot age in hours", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const writtenAt = Date.now() - 2 * 3_600_000; // 2h old
|
||||
const diskSnapshotReader = emptyThenSnapshotReader({
|
||||
rawModels: [MODEL_CLAUDE],
|
||||
rawCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
writtenAt,
|
||||
});
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", features: { diskCache: true } },
|
||||
{ readAuthJson, fetcher, combosFetcher, diskSnapshotReader, logger }
|
||||
);
|
||||
|
||||
await hook(makeInput());
|
||||
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("using stale disk cache (1 models, age 2h)")),
|
||||
"stale-fallback warning includes the computed snapshot age"
|
||||
);
|
||||
});
|
||||
|
||||
test('config: stale-fallback warning falls back to "unknown" age without writtenAt', async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const diskSnapshotReader = emptyThenSnapshotReader({
|
||||
rawModels: [MODEL_CLAUDE],
|
||||
rawCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
});
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", features: { diskCache: true } },
|
||||
{ readAuthJson, fetcher, combosFetcher, diskSnapshotReader, logger }
|
||||
);
|
||||
|
||||
await hook(makeInput());
|
||||
|
||||
assert.ok(
|
||||
logger.entries.some((e) =>
|
||||
String(e[0]).includes("using stale disk cache (1 models, age unknown)")
|
||||
),
|
||||
'stale-fallback warning falls back to "unknown" when writtenAt is absent'
|
||||
);
|
||||
});
|
||||
|
||||
test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
@@ -1443,7 +1376,10 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
|
||||
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
|
||||
assert.equal(entry.models["claude-tier"].name, "Claude Tier");
|
||||
@@ -1559,7 +1495,10 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
const entryA = (inputA as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entryA.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
assert.equal(
|
||||
entryA.models["claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
|
||||
// Second invocation (cache hit) — name must still be single-suffixed.
|
||||
const inputB = makeInput();
|
||||
@@ -1567,7 +1506,10 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
const entryB = (inputB as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entryB.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
assert.equal(
|
||||
entryB.models["claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
> // opencode.json
|
||||
> {
|
||||
> "$schema": "https://opencode.ai/config.json",
|
||||
> "plugin": ["@omniroute/opencode-plugin"],
|
||||
> "plugin": ["@omniroute/opencode-plugin"]
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
@@ -100,7 +100,7 @@ Returns the value to place under `provider.omniroute` inside `opencode.json`.
|
||||
| `baseURL` | `string` | Yes | OmniRoute base URL. Accepts `http://host:port` **or** `http://host:port/v1`. Trailing slashes are tolerated. |
|
||||
| `apiKey` | `string` | Yes | OmniRoute API key. Use `sk_omniroute` for local installs that have `REQUIRE_API_KEY=false`. |
|
||||
| `displayName` | `string` | No | Custom name shown in the OpenCode UI. Default: `"OmniRoute"`. |
|
||||
| `models` | `string[]` | No | Override the surfaced model catalog. Default: 8 curated models — see `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. |
|
||||
| `models` | `string[]` | No | Override the surfaced model catalog. Default: 4 curated models — see `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. |
|
||||
| `modelLabels` | `Record<string,string>` | No | Human-readable labels keyed by model id. |
|
||||
|
||||
Throws on empty/invalid input — `baseURL` must be a real URL, `apiKey` must be a non-empty string.
|
||||
@@ -143,7 +143,7 @@ Duplicates and empty strings are dropped automatically, and order is preserved.
|
||||
|
||||
- **Requests 404 with `/v1/v1/...`** — you're on an old version (≤1.0.0). Update to `≥0.1.0` of this re-released package. The new build normalises `baseURL` automatically.
|
||||
- **`401 Invalid API key`** — your OmniRoute instance has `REQUIRE_API_KEY=true` but the key you supplied doesn't exist there. Create one via the dashboard or set `REQUIRE_API_KEY=false` and use `sk_omniroute`.
|
||||
- **OpenCode complains the provider has no models** — supply an explicit `models` list; the default 8 may be hidden by your provider visibility settings.
|
||||
- **OpenCode complains the provider has no models** — supply an explicit `models` list; the default 4 may be hidden by your provider visibility settings.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 360 LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 359 LLM providers, auto-fallback.
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
|
||||
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
||||
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (178 migrations) |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (176 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
@@ -542,7 +542,6 @@ git push -u origin feat/your-feature
|
||||
**Husky hooks**:
|
||||
|
||||
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` + `check:tracked-artifacts`
|
||||
- **commit-msg**: `check:ai-attribution` — rejects AI/bot `Co-Authored-By` trailers and AI-generation footers in the message (Hard Rule #16; human co-authors allowed; also in the `quality.yml` fast-gates loop (PR→`release/**`) and a PR-only `ci.yml` lint step (PR→`main`) — #14436)
|
||||
- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts`
|
||||
already run on pre-commit; re-running them on every push was pure double-pay. CI still
|
||||
enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.)
|
||||
@@ -665,7 +664,7 @@ focused checks, and use a Conventional Commit message (for example, `docs: slim
|
||||
## Environment
|
||||
|
||||
- **Runtime**: Node.js ≥22.22.2 <23 || ≥24.0.0 <27, ES Modules. This is the **only supported** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun. A **best-effort `bun:sqlite` compatibility path** exists so a global Bun install (`bun install -g omniroute`) can start without `better-sqlite3` (driver adapter + Bun-aware process spawning); it is **not** a supported runtime — no support guarantees — and every Bun-specific runtime change MUST preserve the Node driver/fallback chain and ship a Bun test (`test:bun:db`) or an explicit reason why the path is Node-only.
|
||||
- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.4.2` 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`).
|
||||
- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.4.0` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those scripts with `bun: not found`).
|
||||
- **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)
|
||||
|
||||
983
CHANGELOG.md
983
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
34
Dockerfile
34
Dockerfile
@@ -106,8 +106,7 @@ RUN test -f package-lock.json \
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
|
||||
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& (cd node_modules/better-sqlite3 \
|
||||
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild --force_build=1) \
|
||||
&& test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node \
|
||||
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()" \
|
||||
&& node -e "const wreq=require('wreq-js'); if(typeof wreq.createTransport!=='function') process.exit(1)"
|
||||
|
||||
@@ -226,19 +225,7 @@ ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"
|
||||
|
||||
# Data directory inside Docker — must match the volume mount in docker-compose.yml
|
||||
ENV DATA_DIR=/app/data
|
||||
RUN mkdir -p /app/data && chown node:node /app /app/data
|
||||
|
||||
# #13679: default the PUBLISHED image to requiring an API key. A bare
|
||||
# `docker run -p 20128:20128 … diegosouzapw/omniroute` (README/QUICK-START
|
||||
# one-liners) does not pass `--env-file .env`, so without this default the
|
||||
# anonymous /v1 LLM proxy would be both keyless AND world-reachable on the
|
||||
# published container. This does NOT change the npm/CLI local-dev default
|
||||
# (`REQUIRE_API_KEY` stays `"false"` in featureFlagDefinitions.ts) — only the
|
||||
# shipped deployment artifact's posture. docker-compose.yml is unaffected: it
|
||||
# loads the operator's own `.env` (env_file:) which overrides this ENV, and
|
||||
# already binds loopback-only by default (#12568). Override with
|
||||
# `-e REQUIRE_API_KEY=false` for an intentionally keyless deployment.
|
||||
ENV REQUIRE_API_KEY=true
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# `npm run build` (build-next-isolated → assembleStandalone) bundles ALL runtime
|
||||
# files into .build/next/standalone/ — .next, node_modules, migrations, scripts,
|
||||
@@ -248,24 +235,23 @@ ENV REQUIRE_API_KEY=true
|
||||
# The old per-module overrides were therefore pure duplication and were removed
|
||||
# (build-output-isolation cleanup). See scripts/build/assembleStandalone.mjs
|
||||
# (EXTRA_MODULE_ENTRIES) for the single source of truth.
|
||||
COPY --chown=node:node --from=builder /app/.build/next/standalone ./
|
||||
COPY --from=builder /app/.build/next/standalone ./
|
||||
# better-sqlite3 is the one exception still copied explicitly: assembleStandalone
|
||||
# only syncs its native build/ dir; the JS wrapper (lib/, package.json) is left to
|
||||
# Next.js tracing. bootstrap-env requires SQLite BEFORE the standalone server
|
||||
# starts, so guarantee the complete package independent of trace behaviour.
|
||||
COPY --chown=node:node --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
||||
RUN test -f /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node
|
||||
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
||||
# migrations land at <standalone>/migrations via assembleStandalone; point the runtime at them.
|
||||
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
|
||||
|
||||
# Docker healthcheck script — not traced by Next.js standalone output, so copy
|
||||
# it explicitly. The HEALTHCHECK CMD references it as `node healthcheck.mjs`.
|
||||
COPY --chown=node:node --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
|
||||
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
|
||||
|
||||
# Every COPY above hands its files to the baked-in `node` non-root user
|
||||
# (UID/GID 1000) at copy time. Do NOT add a `RUN chown -R node:node /app`
|
||||
# afterwards: in the overlay filesystem changing ownership rewrites every file
|
||||
# into a new layer, which stored the ~2 GB standalone build twice (#13990).
|
||||
# Hand /app over to the baked-in `node` non-root user (UID/GID 1000) so the
|
||||
# runtime process never holds root privileges. The chown happens after all
|
||||
# COPYs so it covers files originally owned by root in the builder stage.
|
||||
RUN chown -R node:node /app
|
||||
|
||||
EXPOSE 20128
|
||||
|
||||
@@ -354,7 +340,7 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe
|
||||
# build, not the floating `@latest`.
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
|
||||
npm install -g --no-audit --no-fund \
|
||||
@openai/codex@0.155.0 \
|
||||
@openai/codex@0.153.4 \
|
||||
@anthropic-ai/claude-code@2.1.260 \
|
||||
droid@0.212.0 \
|
||||
openclaw@2026.9.1
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
|
||||
FROM oven/bun:1.4.2-slim AS base
|
||||
FROM oven/bun:1.4.0-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
@@ -56,7 +56,7 @@ ENV NODE_ENV=production
|
||||
RUN bun run --quiet build
|
||||
|
||||
# ── Runner Base stage (100% Bun Native Production Runtime) ──────────────────
|
||||
FROM oven/bun:1.4.2-slim AS runner-base
|
||||
FROM oven/bun:1.4.0-slim AS runner-base
|
||||
|
||||
LABEL org.opencontainers.image.title="omniroute" \
|
||||
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \
|
||||
|
||||
42
README.md
42
README.md
@@ -7,19 +7,19 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 360 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 360 AI providers · 150+ free tiers · ~1.62B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 359 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 359 AI providers · 150+ free tiers · ~1.47B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 💰 ~1.62B Free Tokens / Month
|
||||
## 💰 ~1.47B Free Tokens / Month
|
||||
|
||||
</div>
|
||||
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **489 free-tier entries across 35 recurring pool keys** and computes the token headline from the **17 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **446 free-tier entries across 34 recurring pool keys** and computes the token headline from the **16 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
|
||||
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.62B free tokens per month steady, up to ~2.22B in the first month with signup credits, from 35 documented recurring pool keys covering 489 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 17 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, xKiro 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.47B free tokens per month steady, up to ~2.07B in the first month with signup credits, from 34 documented recurring pool keys covering 446 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 16 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
|
||||
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
|
||||
>
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
|
||||
| ------------------------- | :-----: | :-----------------------: | :---------: |
|
||||
| 🌐 Providers | 290 | **357** | more queued |
|
||||
| 🌐 Providers | 290 | **352** | more queued |
|
||||
| 🧠 Unique chat model IDs | 1185 | **1312** | — |
|
||||
| 🖼️ Modality Bridge | — | 🆕 vision + audio + video | — |
|
||||
| 📡 Radar free catalog | — | 🆕 opt-in | — |
|
||||
@@ -101,7 +101,7 @@
|
||||
<tr>
|
||||
<td align="right"><b>⚙️ Features</b></td>
|
||||
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
|
||||
<td align="center"><a href="#-357-ai-providers--152-catalog-marked-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-352-ai-providers--154-catalog-marked-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI & MCP</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -233,7 +233,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 360 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 360 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 54 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 359 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 359 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
@@ -486,7 +486,7 @@ All **19** strategies — mix & match per combo step:
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 360 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 359 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
@@ -542,9 +542,9 @@ Pix copia-e-cola:
|
||||
|
||||
## 📡 OmniRoute Radar
|
||||
|
||||
The main free-tier headline remains **~1.62B tokens/month** from the documented,
|
||||
The main free-tier headline remains **~1.47B tokens/month** from the documented,
|
||||
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
|
||||
month to **~2.22B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
month to **~2.07B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
free-model availability between OmniRoute releases; the community catalog and every existing free
|
||||
feature remain free.
|
||||
|
||||
@@ -629,13 +629,13 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
<td align="center" width="76"><picture><source media="(prefers-color-scheme:dark)" srcset="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.91.0/dark/goose.png"/><img src="https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.91.0/icons/goose.svg" width="40" alt="Goose"/></picture><br/><sub><b>Goose</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Open Interpreter"/><br/><sub><b>Open Interpreter</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Warp AI"/><br/><sub><b>Warp AI</b></sub><br/><sub> </sub></td>
|
||||
<td align="center" width="76"><a href="https://deyin.ai"><img src="./public/deyin.svg" width="40" alt="deyin.ai"/><br/><sub><b>deyin.ai</b></sub><br/><sub> </sub></a></td>
|
||||
<td align="center" width="76"><img src="./public/providers/cli-generic.svg" width="40" alt="Agent Deck"/><br/><sub><b>Agent Deck</b></sub><br/><sub> </sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<b>+ also works with</b> · Agent Deck · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
|
||||
<b>+ also works with</b> · Kiro · Command Code · Antigravity · Windsurf · AMP · <b>any OpenAI-compatible tool</b>
|
||||
</div>
|
||||
|
||||
<sub>📖 Per-tool setup for all 36 tools (26 CLI Code's + 10 CLI Agents) → [`docs/reference/CLI-TOOLS.md`](docs/reference/CLI-TOOLS.md) · 🧩 OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)</sub>
|
||||
@@ -668,11 +668,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
|
||||
<div align="center">
|
||||
|
||||
## 🌐 357 AI Providers — 152 Catalog-Marked Free
|
||||
## 🌐 352 AI Providers — 152 Catalog-Marked Free
|
||||
|
||||
</div>
|
||||
|
||||
> **357 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **491 per-model rows**, **35 recurring pools** and **54 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
|
||||
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **443 per-model rows**, **34 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -862,7 +862,7 @@ with a scoped access token; every command then targets the remote.
|
||||
|
||||
```bash
|
||||
omniroute connect 192.168.0.15 # password → scoped token, saved as a context
|
||||
omniroute models # ← runs against the REMOTE server
|
||||
omniroute models list # ← runs against the REMOTE server
|
||||
omniroute configure codex # ← picks a remote model, writes a local Codex profile
|
||||
omniroute tokens create --name ci --scope read # mint narrower tokens for other machines
|
||||
omniroute contexts use default # ← switch back to the local server
|
||||
@@ -1006,10 +1006,6 @@ omniroute
|
||||
```
|
||||
|
||||
> 💡 See `npm warn ERESOLVE` or peer-dep warnings? [They're harmless](docs/guides/TROUBLESHOOTING.md#npm-install-warnings-eresolve--peer--deprecated).
|
||||
> **Using Gemini Web or another web-cookie provider?** The npm package includes
|
||||
> Playwright but not its Chromium binary. See the
|
||||
> [Playwright Chromium setup](docs/guides/TROUBLESHOOTING.md#gemini-web-and-playwright-chromium)
|
||||
> note before making the first web-provider request.
|
||||
|
||||
Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`.
|
||||
|
||||
@@ -1152,9 +1148,7 @@ install never blocks on compiling from source: it uses a prebuilt binary when on
|
||||
your platform/Node, and otherwise falls back transparently to a pure-JS engine
|
||||
(`node:sqlite` on Node 22+, else the bundled `sql.js` WASM) — no build tools required.
|
||||
|
||||
To skip the post-install **native warm-up** entirely (CI, headless, or slow machines).
|
||||
Note: this only skips the native SQLite warm-up step (`scripts/postinstall.mjs`); the
|
||||
binary-copy/repair hook (`scripts/build/postinstall.mjs`) still runs normally:
|
||||
To skip the post-install native warm-up entirely (CI, headless, or slow machines):
|
||||
|
||||
```bash
|
||||
OMNIROUTE_SKIP_POSTINSTALL=1 npm install -g omniroute # CI=1 also skips it
|
||||
@@ -1274,7 +1268,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>>=22.22.2 <23 || >=24.0.0 <27</code></td></tr>
|
||||
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
|
||||
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 178 migrations</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 176 migrations</td></tr>
|
||||
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
|
||||
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
|
||||
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
|
||||
@@ -1337,7 +1331,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>16-factor scoring, mode packs, self-healing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 35 documented recurring pools / 489 cataloged free-tier entries</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 34 documented recurring pools / 446 cataloged free-tier entries</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -218,8 +218,6 @@ These rules are enforced by tooling and reviewers:
|
||||
|
||||
## Supply-chain scanner findings (Socket.dev / Snyk / similar)
|
||||
|
||||
> **Scope note:** `socket.yml` at the repository root only shapes `projectIgnorePaths` for Socket.dev's registry-side post-publish scan of the published npm artifact — it is not an enforced CI/PR merge gate. No workflow in `.github/workflows`, no `package.json` script, and no `Makefile` target invokes Socket.dev.
|
||||
|
||||
The published `omniroute` npm artifact bundles the Next.js `output: "standalone"`
|
||||
build, which means every route handler — including documented privileged
|
||||
features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* OmniRoute Antigravity Bridge Proxy
|
||||
*
|
||||
* Intercepts Antigravity CLI and IDE requests:
|
||||
* - Directs Gemini 3.8 models directly to Google backend (100% native, untouched).
|
||||
* - Directs other models (Claude Sonnet 4.5/4.6, Opus, Gemini 3.7, GPT-OSS, etc.) to OmniRoute /v1/antigravity.
|
||||
* - Passes all non-model Google requests (auth, onboarding, telemetry) directly to Google backend.
|
||||
* - Transparently forwards all other non-target internet traffic.
|
||||
*/
|
||||
|
||||
import net from "node:net";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import tls from "node:tls";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const PORT = parseInt(process.env.BRIDGE_PORT || "20129", 10);
|
||||
const ROUTER_URL = process.env.ROUTER_URL || "http://127.0.0.1:20128/v1/antigravity";
|
||||
const ROUTER_API_KEY =
|
||||
process.env.ROUTER_API_KEY || process.env.OMNIROUTE_API_KEY || "sk-omniroute-bridge-local";
|
||||
|
||||
// Connection pool agents with TCP keep-alive
|
||||
const httpAgent = new http.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 60000,
|
||||
maxSockets: 64,
|
||||
maxFreeSockets: 16,
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 60000,
|
||||
maxSockets: 64,
|
||||
maxFreeSockets: 16,
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
let cachedSslOptions = null;
|
||||
function getSslOptions() {
|
||||
if (cachedSslOptions) return cachedSslOptions;
|
||||
const certDir =
|
||||
process.env.CERT_DIR || path.join(process.env.HOME || process.cwd(), ".omniroute", "mitm");
|
||||
const serverKey = path.join(certDir, "server.key");
|
||||
const serverCrt = path.join(certDir, "server.crt");
|
||||
|
||||
if (!fs.existsSync(serverKey) || !fs.existsSync(serverCrt)) {
|
||||
console.error("❌ Certificate files not found in", certDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
cachedSslOptions = {
|
||||
key: fs.readFileSync(serverKey),
|
||||
cert: fs.readFileSync(serverCrt),
|
||||
};
|
||||
return cachedSslOptions;
|
||||
}
|
||||
|
||||
const TARGET_HOSTS = new Set([
|
||||
"cloudcode-pa.googleapis.com",
|
||||
"daily-cloudcode-pa.googleapis.com",
|
||||
"daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
"autopush-cloudcode-pa.sandbox.googleapis.com",
|
||||
"preprod-daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
"antigravity-unleash.goog",
|
||||
]);
|
||||
|
||||
function isGenerationRequest(url) {
|
||||
if (!url) return false;
|
||||
return (
|
||||
url.includes(":generateContent") ||
|
||||
url.includes(":streamGenerateContent") ||
|
||||
url.includes("/GenerateChat") ||
|
||||
url.includes("/StreamGenerateChat") ||
|
||||
url.includes("/GenerateCode") ||
|
||||
url.includes("/CompleteCode")
|
||||
);
|
||||
}
|
||||
|
||||
function extractModel(body, url) {
|
||||
if (body && typeof body === "object") {
|
||||
if (typeof body.model === "string" && body.model) return body.model;
|
||||
if (body.request && typeof body.request.model === "string" && body.request.model) {
|
||||
return body.request.model;
|
||||
}
|
||||
}
|
||||
if (url) {
|
||||
try {
|
||||
const parsed = new URL(url, "https://cloudcode-pa.googleapis.com");
|
||||
const m = parsed.searchParams.get("model");
|
||||
if (m) return m;
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const MODEL_ROUTING_MAP = {
|
||||
// Official OmniRoute Auto Groups
|
||||
"auto/best-fast": "groq/openai/gpt-oss-120b",
|
||||
"auto/best-coding": "mistral/codestral-latest",
|
||||
"auto/best-reasoning": "nvidia/nvidia/nemotron-3-super-120b-a12b",
|
||||
"auto/best-free": "groq/qwen/qwen3.8-27b",
|
||||
"auto/best-vision": "nvidia/meta/llama-3.2-90b-vision-instruct",
|
||||
"auto/coding:pro": "mistral/codestral-latest",
|
||||
"auto/coding:fast": "groq/openai/gpt-oss-120b",
|
||||
"auto/coding:free": "groq/qwen/qwen3.8-27b",
|
||||
"auto/coding:reliable": "mistral/codestral-latest",
|
||||
"auto/reasoning:pro": "nvidia/nvidia/nemotron-3-super-120b-a12b",
|
||||
"auto/smart": "nvidia/nvidia/nemotron-3-super-120b-a12b",
|
||||
"auto/claude-sonnet": "mistral/codestral-latest",
|
||||
"auto/claude-opus": "nvidia/nvidia/nemotron-3-super-120b-a12b",
|
||||
"auto/gemini": "gemini/gemini-2.5-flash",
|
||||
"auto/llama": "groq/openai/gpt-oss-120b",
|
||||
"auto/gemma": "groq/qwen/qwen3.8-27b",
|
||||
|
||||
// Human-readable Display Names (in case CLI sends displayName in envelope)
|
||||
"Auto: Best Fast (OmniRoute)": "groq/openai/gpt-oss-120b",
|
||||
"Auto: Best Coding (OmniRoute)": "mistral/codestral-latest",
|
||||
"Auto: Best Reasoning (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
|
||||
"Auto: Best Free (OmniRoute)": "groq/qwen/qwen3.8-27b",
|
||||
"Auto: Best Vision (OmniRoute)": "nvidia/meta/llama-3.2-90b-vision-instruct",
|
||||
"Auto: Coding Pro (OmniRoute)": "mistral/codestral-latest",
|
||||
"Auto: Coding Fast (OmniRoute)": "groq/openai/gpt-oss-120b",
|
||||
"Auto: Coding Free (OmniRoute)": "groq/qwen/qwen3.8-27b",
|
||||
"Auto: Coding Reliable (OmniRoute)": "mistral/codestral-latest",
|
||||
"Auto: Reasoning Pro (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
|
||||
"Auto: Smart (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
|
||||
"Auto: Claude Sonnet (OmniRoute)": "mistral/codestral-latest",
|
||||
"Auto: Claude Opus (OmniRoute)": "nvidia/nvidia/nemotron-3-super-120b-a12b",
|
||||
"Auto: Gemini (OmniRoute)": "gemini/gemini-2.5-flash",
|
||||
"Auto: Llama (OmniRoute)": "groq/openai/gpt-oss-120b",
|
||||
"Auto: Gemma (OmniRoute)": "groq/qwen/qwen3.8-27b",
|
||||
|
||||
// Fail-safe self-healing for dead/retired models
|
||||
"nvidia/deepseek-ai/deepseek-v4-pro-0813": "groq/openai/gpt-oss-120b",
|
||||
"deepseek-ai/deepseek-v4-pro-0813": "groq/openai/gpt-oss-120b",
|
||||
"NVIDIA: DeepSeek V4 Pro": "groq/openai/gpt-oss-120b",
|
||||
"nvidia/openai/gpt-oss-120b": "groq/openai/gpt-oss-120b",
|
||||
"openai/gpt-oss-120b": "groq/openai/gpt-oss-120b",
|
||||
"groq/llama-3.3-70b-versatile": "groq/openai/gpt-oss-120b",
|
||||
"llama-3.3-70b-versatile": "groq/openai/gpt-oss-120b",
|
||||
};
|
||||
|
||||
function resolveTargetModel(model) {
|
||||
if (!model) return "groq/openai/gpt-oss-120b";
|
||||
if (MODEL_ROUTING_MAP[model]) return MODEL_ROUTING_MAP[model];
|
||||
const clean = model.replace(/^models\//, "").trim();
|
||||
if (MODEL_ROUTING_MAP[clean]) return MODEL_ROUTING_MAP[clean];
|
||||
for (const [k, v] of Object.entries(MODEL_ROUTING_MAP)) {
|
||||
if (k.toLowerCase() === model.toLowerCase() || k.toLowerCase() === clean.toLowerCase()) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
if (
|
||||
clean.includes("deepseek-v4-pro") ||
|
||||
(clean.startsWith("nvidia") && clean.includes("gpt-oss-120b")) ||
|
||||
clean.includes("llama-3.3-70b-versatile")
|
||||
) {
|
||||
return "groq/openai/gpt-oss-120b";
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
const OMNIROUTE_BUILTIN_GROUPS = [
|
||||
{
|
||||
id: "auto/best-coding",
|
||||
displayName: "Auto: Best Coding (OmniRoute)",
|
||||
descriptionText:
|
||||
"OmniRoute dynamic routing to the highest benchmark coding model available (Mistral Codestral)",
|
||||
},
|
||||
{
|
||||
id: "auto/best-reasoning",
|
||||
displayName: "Auto: Best Reasoning (OmniRoute)",
|
||||
descriptionText:
|
||||
"OmniRoute dynamic routing to the highest benchmark reasoning model available (Nemotron 3 Super 120B)",
|
||||
},
|
||||
{
|
||||
id: "auto/best-fast",
|
||||
displayName: "Auto: Best Fast (OmniRoute)",
|
||||
descriptionText: "OmniRoute sub-second lowest latency high-throughput model (Groq LPUs)",
|
||||
},
|
||||
{
|
||||
id: "auto/best-vision",
|
||||
displayName: "Auto: Best Vision (OmniRoute)",
|
||||
descriptionText: "OmniRoute multimodal & computer vision routing",
|
||||
},
|
||||
{
|
||||
id: "auto/best-free",
|
||||
displayName: "Auto: Best Free (OmniRoute)",
|
||||
descriptionText: "OmniRoute 100% unmetered free tier model routing (Qwen 3.8 27B)",
|
||||
},
|
||||
{
|
||||
id: "auto/coding:pro",
|
||||
displayName: "Auto: Coding Pro (OmniRoute)",
|
||||
descriptionText: "OmniRoute frontier pro-tier coding model (Codestral)",
|
||||
},
|
||||
{
|
||||
id: "auto/coding:fast",
|
||||
displayName: "Auto: Coding Fast (OmniRoute)",
|
||||
descriptionText: "OmniRoute fast sub-second daily coding model (Groq 120B)",
|
||||
},
|
||||
{
|
||||
id: "auto/coding:free",
|
||||
displayName: "Auto: Coding Free (OmniRoute)",
|
||||
descriptionText: "OmniRoute zero-cost free coding model",
|
||||
},
|
||||
{
|
||||
id: "auto/coding:reliable",
|
||||
displayName: "Auto: Coding Reliable (OmniRoute)",
|
||||
descriptionText: "OmniRoute maximum uptime and reliability coding model",
|
||||
},
|
||||
{
|
||||
id: "auto/reasoning:pro",
|
||||
displayName: "Auto: Reasoning Pro (OmniRoute)",
|
||||
descriptionText: "OmniRoute deep reasoning frontier model",
|
||||
},
|
||||
{
|
||||
id: "auto/smart",
|
||||
displayName: "Auto: Smart (OmniRoute)",
|
||||
descriptionText: "OmniRoute highest intelligence general-purpose model",
|
||||
},
|
||||
{
|
||||
id: "auto/claude-sonnet",
|
||||
displayName: "Auto: Claude Sonnet (OmniRoute)",
|
||||
descriptionText: "OmniRoute automated routing across Claude Sonnet providers",
|
||||
},
|
||||
{
|
||||
id: "auto/claude-opus",
|
||||
displayName: "Auto: Claude Opus (OmniRoute)",
|
||||
descriptionText: "OmniRoute automated routing across Claude Opus providers",
|
||||
},
|
||||
{
|
||||
id: "auto/gemini",
|
||||
displayName: "Auto: Gemini (OmniRoute)",
|
||||
descriptionText: "OmniRoute automated routing across Gemini providers",
|
||||
},
|
||||
{
|
||||
id: "auto/llama",
|
||||
displayName: "Auto: Llama (OmniRoute)",
|
||||
descriptionText: "OmniRoute automated routing across Llama providers",
|
||||
},
|
||||
{
|
||||
id: "auto/gemma",
|
||||
displayName: "Auto: Gemma (OmniRoute)",
|
||||
descriptionText: "OmniRoute automated routing across Gemma providers",
|
||||
},
|
||||
// Active, verified provider models
|
||||
{
|
||||
id: "groq/openai/gpt-oss-120b",
|
||||
displayName: "Groq: GPT-OSS 120B (Ultra-Fast 0.02s)",
|
||||
descriptionText: "Ultra-fast inference on Groq LPUs at sub-second speeds",
|
||||
},
|
||||
{
|
||||
id: "groq/qwen/qwen3.8-27b",
|
||||
displayName: "Groq: Qwen 3.8 27B",
|
||||
descriptionText: "High-speed Qwen 3.8 27B model on Groq",
|
||||
},
|
||||
{
|
||||
id: "mistral/codestral-latest",
|
||||
displayName: "Mistral: Codestral Latest",
|
||||
descriptionText: "Mistral flagship frontier code reasoning model",
|
||||
},
|
||||
{
|
||||
id: "nvidia/nvidia/nemotron-3-super-120b-a12b",
|
||||
displayName: "NVIDIA: Nemotron 3 Super 120B",
|
||||
descriptionText: "Nemotron 3 Super 120B Deep Reasoning model on NVIDIA NIM",
|
||||
},
|
||||
{
|
||||
id: "gemini/gemini-2.5-flash",
|
||||
displayName: "Gemini: Gemini 2.5 Flash (AI Studio)",
|
||||
descriptionText: "Google AI Studio direct Gemini 2.5 Flash route",
|
||||
},
|
||||
{
|
||||
id: "gemini/gemini-2.5-pro",
|
||||
displayName: "Gemini: Gemini 2.5 Pro (AI Studio)",
|
||||
descriptionText: "Google AI Studio direct Gemini 2.5 Pro route",
|
||||
},
|
||||
];
|
||||
|
||||
const OMNIROUTE_CUSTOM_MODELS = new Set([
|
||||
...OMNIROUTE_BUILTIN_GROUPS.map((g) => g.id),
|
||||
...Object.keys(MODEL_ROUTING_MAP),
|
||||
]);
|
||||
|
||||
function shouldInterceptToOmniRoute(model, url) {
|
||||
if (!model) return false;
|
||||
|
||||
// Never intercept non-streaming unary RPCs (Antigravity expects raw JSON/Protobuf, not SSE)
|
||||
const isStreaming =
|
||||
url.includes("streamGenerateContent") ||
|
||||
url.includes("StreamGenerateChat") ||
|
||||
url.includes("alt=sse");
|
||||
if (!isStreaming) return false;
|
||||
|
||||
// Never intercept native Google/Gemini models (used by Antigravity core, subagents, websearch, grounding)
|
||||
if (model.startsWith("gemini-") || model.startsWith("models/gemini-")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never intercept native Google CloudCode PA hosted models
|
||||
if (
|
||||
model === "claude-sonnet-4-6" ||
|
||||
model === "claude-opus-4-6" ||
|
||||
model === "gpt-oss-120b-medium"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Intercept any OmniRoute auto group, provider model, or mapped alias
|
||||
const clean = model.replace(/^models\//, "").trim();
|
||||
if (
|
||||
clean.startsWith("auto/") ||
|
||||
clean.toLowerCase().includes("omniroute") ||
|
||||
clean.includes("/") ||
|
||||
OMNIROUTE_CUSTOM_MODELS.has(model) ||
|
||||
OMNIROUTE_CUSTOM_MODELS.has(clean) ||
|
||||
Boolean(MODEL_ROUTING_MAP[model]) ||
|
||||
Boolean(MODEL_ROUTING_MAP[clean])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const internalApp = http.createServer(async (req, res) => {
|
||||
const host = (req.headers.host || "cloudcode-pa.googleapis.com").split(":")[0];
|
||||
const url = req.url || "/";
|
||||
|
||||
// Collect request body
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const bodyBuffer = Buffer.concat(chunks);
|
||||
|
||||
let bodyJson = null;
|
||||
if (bodyBuffer.length > 0) {
|
||||
try {
|
||||
bodyJson = JSON.parse(bodyBuffer.toString("utf-8"));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const model = extractModel(bodyJson, url);
|
||||
const shouldIntercept = shouldInterceptToOmniRoute(model, url);
|
||||
|
||||
if (shouldIntercept) {
|
||||
const resolvedModel = resolveTargetModel(model);
|
||||
console.log(
|
||||
`[Bridge] 🔀 INTERCEPTING -> OmniRoute: "${model || "default"}" => "${resolvedModel}" (${url})`
|
||||
);
|
||||
|
||||
let outgoingBuffer = bodyBuffer;
|
||||
if (bodyJson) {
|
||||
const cloned = JSON.parse(JSON.stringify(bodyJson));
|
||||
cloned.model = resolvedModel;
|
||||
if (cloned.request && typeof cloned.request === "object") {
|
||||
cloned.request.model = resolvedModel;
|
||||
}
|
||||
outgoingBuffer = Buffer.from(JSON.stringify(cloned), "utf-8");
|
||||
}
|
||||
|
||||
// Forward to OmniRoute /v1/antigravity
|
||||
try {
|
||||
const forwardHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(outgoingBuffer),
|
||||
Authorization: `Bearer ${ROUTER_API_KEY}`,
|
||||
"x-omniroute-source": "agent-bridge",
|
||||
"x-omniroute-agent": "antigravity",
|
||||
"x-omniroute-skip-usage": "true", // Skip usage tracking for default models
|
||||
};
|
||||
|
||||
const upstreamReq = http.request(
|
||||
ROUTER_URL,
|
||||
{
|
||||
method: "POST",
|
||||
headers: forwardHeaders,
|
||||
agent: httpAgent,
|
||||
},
|
||||
(upstreamRes) => {
|
||||
res.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
||||
upstreamRes.pipe(res);
|
||||
}
|
||||
);
|
||||
upstreamReq.setNoDelay(true);
|
||||
|
||||
upstreamReq.on("error", (err) => {
|
||||
console.error(`[Bridge] ❌ Error forwarding to OmniRoute: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { message: `OmniRoute bridge error: ${err.message}` } }));
|
||||
}
|
||||
});
|
||||
|
||||
upstreamReq.write(outgoingBuffer);
|
||||
upstreamReq.end();
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error(`[Bridge] ❌ Failed to invoke OmniRoute: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise: Passthrough directly to Google upstream
|
||||
console.log(`[Bridge] ⏩ PASSTHROUGH -> Google: ${model || "non-model"} (${url})`);
|
||||
|
||||
const upstreamHeaders = { ...req.headers };
|
||||
delete upstreamHeaders["host"]; // Let https.request set the correct Host
|
||||
upstreamHeaders["host"] = host;
|
||||
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
delete upstreamHeaders["accept-encoding"];
|
||||
}
|
||||
|
||||
const googleReq = https.request(
|
||||
{
|
||||
hostname: host,
|
||||
port: 443,
|
||||
path: url,
|
||||
method: req.method,
|
||||
headers: upstreamHeaders,
|
||||
agent: httpsAgent,
|
||||
},
|
||||
(googleRes) => {
|
||||
if (url.includes("fetchAvailableModels")) {
|
||||
const respChunks = [];
|
||||
googleRes.on("data", (chunk) => respChunks.push(chunk));
|
||||
googleRes.on("end", () => {
|
||||
const respBuffer = Buffer.concat(respChunks);
|
||||
let finalBuffer = respBuffer;
|
||||
try {
|
||||
const data = JSON.parse(respBuffer.toString("utf-8"));
|
||||
if (data && data.models) {
|
||||
// Inject OmniRoute built-in auto groups and models
|
||||
const baseTemplate =
|
||||
data.models["claude-sonnet-4-6"] ||
|
||||
data.models["gpt-oss-120b-medium"] ||
|
||||
Object.values(data.models)[0] ||
|
||||
{};
|
||||
|
||||
const injectedIds = [];
|
||||
for (const group of OMNIROUTE_BUILTIN_GROUPS) {
|
||||
data.models[group.id] = {
|
||||
...baseTemplate,
|
||||
id: group.id,
|
||||
name: group.id,
|
||||
displayName: group.displayName,
|
||||
descriptionText: group.descriptionText,
|
||||
};
|
||||
injectedIds.push(group.id);
|
||||
}
|
||||
|
||||
// Prepend OmniRoute groups to agentModelSorts recommended group
|
||||
if (
|
||||
Array.isArray(data.agentModelSorts) &&
|
||||
data.agentModelSorts[0]?.groups?.[0]?.modelIds
|
||||
) {
|
||||
const existing = data.agentModelSorts[0].groups[0].modelIds;
|
||||
data.agentModelSorts[0].groups[0].modelIds = [
|
||||
...injectedIds,
|
||||
...existing.filter((id) => !injectedIds.includes(id)),
|
||||
];
|
||||
}
|
||||
finalBuffer = Buffer.from(JSON.stringify(data), "utf-8");
|
||||
console.log(
|
||||
`[Bridge] 🌟 Injected custom models into fetchAvailableModels (${finalBuffer.length} bytes)`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Bridge] ⚠️ Error modifying fetchAvailableModels: ${err.message}`);
|
||||
}
|
||||
|
||||
const headers = { ...googleRes.headers };
|
||||
delete headers["content-length"];
|
||||
delete headers["content-encoding"];
|
||||
headers["content-length"] = String(finalBuffer.length);
|
||||
res.writeHead(googleRes.statusCode || 200, headers);
|
||||
res.end(finalBuffer);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(googleRes.statusCode || 200, googleRes.headers);
|
||||
googleRes.pipe(res);
|
||||
}
|
||||
);
|
||||
googleReq.setNoDelay(true);
|
||||
|
||||
googleReq.on("error", (err) => {
|
||||
console.error(`[Bridge] ❌ Google upstream error: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { message: `Google upstream error: ${err.message}` } }));
|
||||
}
|
||||
});
|
||||
|
||||
if (bodyBuffer.length > 0) {
|
||||
googleReq.write(bodyBuffer);
|
||||
}
|
||||
googleReq.end();
|
||||
});
|
||||
|
||||
internalApp.keepAliveTimeout = 65000;
|
||||
internalApp.headersTimeout = 66000;
|
||||
|
||||
// Proxy server listening on HTTP port
|
||||
const proxyServer = http.createServer((req, res) => {
|
||||
// Plain HTTP request (non-CONNECT)
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end("OmniRoute Antigravity Bridge Proxy Active\n");
|
||||
});
|
||||
|
||||
proxyServer.keepAliveTimeout = 65000;
|
||||
proxyServer.headersTimeout = 66000;
|
||||
|
||||
proxyServer.on("connect", (req, clientSocket, head) => {
|
||||
clientSocket.setNoDelay(true);
|
||||
const [targetHost, targetPortStr] = (req.url || "").split(":");
|
||||
const targetPort = parseInt(targetPortStr || "443", 10);
|
||||
|
||||
if (TARGET_HOSTS.has(targetHost)) {
|
||||
// Target host: Terminate TLS locally and route via internalApp
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
|
||||
const ssl = getSslOptions();
|
||||
const tlsSocket = new tls.TLSSocket(clientSocket, {
|
||||
isServer: true,
|
||||
key: ssl.key,
|
||||
cert: ssl.cert,
|
||||
});
|
||||
tlsSocket.setNoDelay(true);
|
||||
|
||||
tlsSocket.on("error", (err) => {
|
||||
// Client closed or TLS error
|
||||
clientSocket.destroy();
|
||||
});
|
||||
|
||||
internalApp.emit("connection", tlsSocket);
|
||||
} else {
|
||||
// Non-target host: Transparent raw TCP tunnel
|
||||
const upstreamSocket = net.connect(targetPort, targetHost, () => {
|
||||
upstreamSocket.setNoDelay(true);
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
if (head && head.length > 0) {
|
||||
upstreamSocket.write(head);
|
||||
}
|
||||
upstreamSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(upstreamSocket);
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
clientSocket.destroy();
|
||||
upstreamSocket.destroy();
|
||||
};
|
||||
|
||||
upstreamSocket.on("error", cleanup);
|
||||
clientSocket.on("error", cleanup);
|
||||
}
|
||||
});
|
||||
|
||||
export {
|
||||
resolveTargetModel,
|
||||
MODEL_ROUTING_MAP,
|
||||
shouldInterceptToOmniRoute,
|
||||
extractModel,
|
||||
OMNIROUTE_BUILTIN_GROUPS,
|
||||
proxyServer,
|
||||
internalApp,
|
||||
};
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isMain) {
|
||||
proxyServer.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`🚀 OmniRoute Antigravity Bridge listening on 127.0.0.1:${PORT}`);
|
||||
console.log(` Routing non-Gemini 3.8 model traffic -> ${ROUTER_URL}`);
|
||||
console.log(` Preserving Gemini 3.8 native traffic -> Google`);
|
||||
});
|
||||
}
|
||||
@@ -79,8 +79,7 @@ export async function runChatCommand(promptArg, opts, cmd) {
|
||||
const data = await response.json();
|
||||
const text = extractText(data, opts.responsesApi);
|
||||
|
||||
// Commander stores `--no-history` as `history === false`, never as `noHistory`.
|
||||
if (opts.history !== false && opts.noHistory !== true) {
|
||||
if (!opts.noHistory) {
|
||||
appendHistory({ prompt, model: opts.model, latencyMs, usage: data.usage, response: text });
|
||||
}
|
||||
|
||||
|
||||
@@ -248,9 +248,7 @@ export function registerContexts(program) {
|
||||
.option("--no-secrets", "Omit API keys from export")
|
||||
.action(async (opts, cmd) => {
|
||||
const cfg = loadContexts();
|
||||
// Commander stores `--no-secrets` as `secrets === false`, never as `noSecrets`.
|
||||
const redact = opts.secrets === false || opts.noSecrets === true;
|
||||
const out = redact ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
|
||||
const out = opts.noSecrets ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg));
|
||||
const json = JSON.stringify(out, null, 2);
|
||||
if (opts.out) {
|
||||
const { writeFileSync } = await import("node:fs");
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getCliToken, CLI_TOKEN_HEADER } from "../utils/cliToken.mjs";
|
||||
import { printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
|
||||
import { getCrashLogPath } from "../runtime/processSupervisor.mjs";
|
||||
|
||||
const STATIC_SALT = "omniroute-field-encryption-v1";
|
||||
const KEY_LENGTH = 32;
|
||||
@@ -381,33 +380,6 @@ function checkMemory() {
|
||||
});
|
||||
}
|
||||
|
||||
// #13538: surfaces the supervisor's give-up crash record (persisted by
|
||||
// ServerSupervisor.persistCrashLog(), bin/cli/runtime/processSupervisor.mjs)
|
||||
// so a user whose `--tray` worker died silently (detached, stdio:"ignore")
|
||||
// has something concrete `doctor` can point at without needing `--log`.
|
||||
function checkCrashLog() {
|
||||
const crashLogPath = getCrashLogPath();
|
||||
if (!fs.existsSync(crashLogPath)) {
|
||||
return ok("Crash log", "No supervisor crash record found", { crashLogPath });
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(crashLogPath);
|
||||
const contents = fs.readFileSync(crashLogPath, "utf8");
|
||||
const lastEntry = contents.split("\n").filter(Boolean).slice(-6).join("\n");
|
||||
return warn(
|
||||
"Crash log",
|
||||
`Supervisor recorded a give-up crash at ${crashLogPath} (last modified ${stat.mtime.toISOString()})`,
|
||||
{ crashLogPath, modifiedAt: stat.mtime.toISOString(), tail: lastEntry }
|
||||
);
|
||||
} catch (error) {
|
||||
return warn("Crash log", `Crash record exists at ${crashLogPath} but could not be read`, {
|
||||
crashLogPath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
||||
@@ -607,7 +579,6 @@ export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
checks.push(await checkNodeRuntime(rootDir));
|
||||
checks.push(await checkNativeBinary(rootDir));
|
||||
checks.push(checkMemory());
|
||||
checks.push(checkCrashLog());
|
||||
|
||||
if (!options.skipLiveness) {
|
||||
checks.push(await checkServerLiveness(options));
|
||||
|
||||
@@ -352,24 +352,10 @@ export async function runKeysRegenerateCommand(id, opts = {}) {
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const encodedId = encodeURIComponent(id);
|
||||
let res = await apiFetch(`/api/v1/registered-keys/${encodedId}/regenerate`, {
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/regenerate`, {
|
||||
method: "POST",
|
||||
retry: false,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
// `keys` predates the split between registered keys and the dashboard's
|
||||
// ordinary API keys. IDs shown by `keys list`/the dashboard belong to
|
||||
// `/api/keys`, while deployment/registered-key IDs belong to
|
||||
// `/api/v1/registered-keys`. Try the ordinary-key route when the ID is not
|
||||
// present in the registered-key store so the command works with either ID.
|
||||
if (isRouteUnavailableStatus(res.status)) {
|
||||
res = await apiFetch(`/api/keys/${encodedId}/regenerate`, {
|
||||
method: "POST",
|
||||
retry: false,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
@@ -424,17 +410,9 @@ export async function runKeysRevealCommand(id, opts = {}) {
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const encodedId = encodeURIComponent(id);
|
||||
let res = await apiFetch(`/api/v1/registered-keys/${encodedId}/reveal`, {
|
||||
const res = await apiFetch(`/api/v1/registered-keys/${encodeURIComponent(id)}/reveal`, {
|
||||
retry: false,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (isRouteUnavailableStatus(res.status)) {
|
||||
res = await apiFetch(`/api/keys/${encodedId}/reveal`, {
|
||||
retry: false,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
}
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
|
||||
@@ -23,13 +23,8 @@ const PROVIDERS_WITH_OAUTH = [
|
||||
// the device-flow request to /api/providers/command-code/auth/start, which is
|
||||
// gated by requireManagementAuth and returned 401 for a fresh CLI context
|
||||
// (issue #9474). Map the alias to the real backend key instead.
|
||||
//
|
||||
// `copilot` has the same mismatch (#14298): the GitHub Copilot device flow is
|
||||
// registered under the backend key `github`, so posting to
|
||||
// /api/oauth/copilot/device-code failed for an unknown provider.
|
||||
const BACKEND_OAUTH_KEY = {
|
||||
"claude-code": "claude",
|
||||
copilot: "github",
|
||||
};
|
||||
|
||||
function resolveBackendKey(id) {
|
||||
@@ -75,7 +70,7 @@ function printLoopbackRedirectWarning(providerId, redirectUri) {
|
||||
process.stdout.write(
|
||||
`Note: the authorize URL below advertises ${redirectUri}, but this CLI does not\n` +
|
||||
"listen on that port. Right after you approve, the browser is expected to\n" +
|
||||
'show a connection error (e.g. "This site can\'t be reached" / \n' +
|
||||
"show a connection error (e.g. \"This site can't be reached\" / \n" +
|
||||
"ERR_CONNECTION_REFUSED) — that is normal, not a failure. Copy the full URL\n" +
|
||||
"from the address bar anyway and paste it below.\n"
|
||||
);
|
||||
@@ -297,51 +292,27 @@ async function runDeviceFlow(def, opts) {
|
||||
|
||||
if (opts.browser !== false && verificationUri) await openBrowser(verificationUri);
|
||||
process.stderr.write("Waiting for device authorization...\n");
|
||||
// Poll the real device-flow route: POST /api/oauth/{key}/poll with the device
|
||||
// code (#14298). The previous implementation polled
|
||||
// GET /api/providers/{key}/auth/status?state=… and then POST …/auth/apply,
|
||||
// but neither route exists on the server, and the device-code response has no
|
||||
// `state` field at all — so the CLI looped until its timeout even after the
|
||||
// user authorized. /api/oauth/{key}/poll is the same route the dashboard
|
||||
// polls (src/shared/components/OAuthModal.tsx::pollDeviceCodeOnce) and it
|
||||
// persists the connection server-side on success, so no separate apply step
|
||||
// is needed.
|
||||
const deviceCode = start.deviceCode ?? start.device_code ?? "";
|
||||
if (!deviceCode) {
|
||||
process.stderr.write("Server did not return a device code; cannot poll for authorization.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
const codeVerifier = start.codeVerifier ?? undefined;
|
||||
const deadline = Date.now() + (opts.timeout ?? 300000);
|
||||
let intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
|
||||
const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(intervalMs);
|
||||
const pollRes = await apiFetch(`/api/oauth/${providerKey}/poll`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
body: { deviceCode, ...(codeVerifier ? { codeVerifier } : {}) },
|
||||
});
|
||||
if (!pollRes.ok) continue;
|
||||
let poll;
|
||||
try {
|
||||
poll = await pollRes.json();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (poll.success) {
|
||||
const conn = poll.connection ?? {};
|
||||
process.stdout.write(
|
||||
`Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n`
|
||||
);
|
||||
const statusRes = await apiFetch(
|
||||
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`,
|
||||
targetApiOptions(opts)
|
||||
);
|
||||
if (!statusRes.ok) continue;
|
||||
const status = await statusRes.json();
|
||||
if (status.status === "complete" || status.status === "authorized") {
|
||||
await apiFetch(`/api/providers/${providerKey}/auth/apply`, {
|
||||
...targetApiOptions(opts),
|
||||
method: "POST",
|
||||
body: { state: start.state },
|
||||
});
|
||||
process.stdout.write(`Authorized: ${status.account ?? status.email ?? "connected"}\n`);
|
||||
return;
|
||||
}
|
||||
if (poll.error === "slow_down") {
|
||||
// OAuth device-flow spec: back off by 5s on slow_down.
|
||||
intervalMs += 5000;
|
||||
continue;
|
||||
}
|
||||
if (poll.error && !poll.pending) {
|
||||
process.stderr.write(`Device auth failed: ${poll.errorDescription ?? poll.error}\n`);
|
||||
if (status.status === "error") {
|
||||
process.stderr.write(`Device auth failed: ${status.error}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ export function registerRestart(program) {
|
||||
program
|
||||
.command("restart")
|
||||
.description(t("restart.description"))
|
||||
// No Commander default: runServe() falls back to PORT, then 20128 (#7049).
|
||||
.option("--port <port>", t("serve.port"))
|
||||
.option("--port <port>", t("serve.port"), "20128")
|
||||
.action(async (opts) => {
|
||||
const exitCode = await runRestartCommand(opts);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
|
||||
@@ -4,13 +4,7 @@ import { join, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { platform, totalmem } from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import {
|
||||
writePidFile,
|
||||
cleanupPidFile,
|
||||
waitForServer,
|
||||
findListeningPids,
|
||||
resolveReadyTimeoutMs,
|
||||
} from "../utils/pid.mjs";
|
||||
import { writePidFile, cleanupPidFile, waitForServer, resolveReadyTimeoutMs } from "../utils/pid.mjs";
|
||||
import {
|
||||
ServerSupervisor,
|
||||
detectMitmCrash,
|
||||
@@ -241,16 +235,6 @@ export async function runServe(opts = {}) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Refuse to start a second instance on a port something else already owns,
|
||||
// BEFORE any pid file is written or any child is spawned. Otherwise the
|
||||
// doomed child's EADDRINUSE arrives only after this process has rewritten
|
||||
// the pid files of the healthy instance that actually owns the port.
|
||||
const busyPids = await findListeningPids(dashboardPort);
|
||||
if (busyPids.length > 0) {
|
||||
reportPortInUse(dashboardPort, busyPids);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
|
||||
|
||||
// #5172/#5160/#5152: default the V8 heap to ~35% of physical RAM (clamped
|
||||
@@ -297,8 +281,7 @@ export async function runServe(opts = {}) {
|
||||
return runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort);
|
||||
}
|
||||
|
||||
// Commander stores `--no-recovery` as `recovery === false`, never as `noRecovery`.
|
||||
if (opts.recovery === false || opts.noRecovery === true) {
|
||||
if (opts.noRecovery) {
|
||||
return runWithoutRecovery(
|
||||
serverJs,
|
||||
env,
|
||||
@@ -321,29 +304,10 @@ export async function runServe(opts = {}) {
|
||||
opts.maxRestarts ?? 2,
|
||||
startedAt,
|
||||
useTray,
|
||||
{
|
||||
trayReadyPort: opts.trayReadyPort,
|
||||
trayReadyToken: opts.trayReadyToken,
|
||||
readyTimeoutMs: resolveReadyTimeoutMs({ timeoutMs: opts.readyTimeout }),
|
||||
}
|
||||
{ trayReadyPort: opts.trayReadyPort, trayReadyToken: opts.trayReadyToken }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain a port conflict in terms the operator can act on: who owns the port,
|
||||
* and the two ways out. Exported for unit tests.
|
||||
*/
|
||||
export function reportPortInUse(port, pids = []) {
|
||||
const owner = pids.length === 1 ? `PID ${pids[0]}` : `PIDs ${pids.join(", ")}`;
|
||||
console.error(`\n\x1b[31m✖ Port ${port} is already in use by ${owner}.\x1b[0m`);
|
||||
console.error(
|
||||
` Another OmniRoute is most likely already serving there, so open` +
|
||||
` ${urlScheme}://localhost:${port} before starting a second one.`
|
||||
);
|
||||
console.error(` To replace it: \x1b[36momniroute stop\x1b[0m, then start again`);
|
||||
console.error(` To run alongside: \x1b[36momniroute serve --port <other-port>\x1b[0m\n`);
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -454,7 +418,7 @@ async function runWithSupervisor(
|
||||
maxRestarts,
|
||||
startedAt,
|
||||
useTray = false,
|
||||
{ trayReadyPort, trayReadyToken, readyTimeoutMs = resolveReadyTimeoutMs() } = {}
|
||||
{ trayReadyPort, trayReadyToken } = {}
|
||||
) {
|
||||
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
|
||||
writePidFile("supervisor", process.pid);
|
||||
@@ -493,12 +457,8 @@ async function runWithSupervisor(
|
||||
});
|
||||
|
||||
if (!showLog) {
|
||||
let lastProbeOutcome = null;
|
||||
waitForServer(dashboardPort, readyTimeoutMs, {
|
||||
onOutcome: (outcome) => {
|
||||
lastProbeOutcome = outcome;
|
||||
},
|
||||
}).then(async (up) => {
|
||||
const readyTimeoutMs = resolveReadyTimeoutMs({ timeoutMs: opts.readyTimeout });
|
||||
waitForServer(dashboardPort, readyTimeoutMs).then(async (up) => {
|
||||
if (up) {
|
||||
if (useTray) {
|
||||
const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
@@ -522,7 +482,7 @@ async function runWithSupervisor(
|
||||
}
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
} else {
|
||||
reportReadinessTimeout(dashboardPort, supervisor, lastProbeOutcome);
|
||||
reportReadinessTimeout(dashboardPort, supervisor);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -534,28 +494,13 @@ async function runWithSupervisor(
|
||||
// stuck (issue reports show the server sometimes actually comes up later, or is
|
||||
// reachable directly while the CLI still looks hung). Surface a clear diagnostic
|
||||
// plus whatever stdout/stderr the child buffered instead of going silent.
|
||||
export function reportReadinessTimeout(dashboardPort, supervisor, lastProbeOutcome = null) {
|
||||
export function reportReadinessTimeout(dashboardPort, supervisor) {
|
||||
const readyTimeoutMs = resolveReadyTimeoutMs();
|
||||
const seconds = Math.round(readyTimeoutMs / 1000);
|
||||
console.error(
|
||||
`\n\x1b[33m⚠ Server did not respond within ${seconds}s.\x1b[0m It may still be starting, or may` +
|
||||
` have failed silently.`
|
||||
);
|
||||
// The last probe classification separates a real boot failure (nothing ever
|
||||
// bound the port, so the buffered output below is the reason) from a server
|
||||
// that IS listening and merely did not answer the health route in time:
|
||||
// very likely usable already, with only the readiness signal timed out.
|
||||
if (lastProbeOutcome === "hanging" || lastProbeOutcome === "fast-reject") {
|
||||
console.error(
|
||||
` Port ${dashboardPort} IS accepting connections, so the server is probably up and` +
|
||||
` still warming up. Check the dashboard before restarting it.`
|
||||
);
|
||||
} else if (lastProbeOutcome === "not-listening") {
|
||||
console.error(
|
||||
` Nothing is listening on port ${dashboardPort}, so the server never bound it and the` +
|
||||
` output below is the reason.`
|
||||
);
|
||||
}
|
||||
console.error(
|
||||
` Tip: set OMNIROUTE_READY_TIMEOUT_MS=${readyTimeoutMs * 2} or --ready-timeout ${readyTimeoutMs * 2} for slower cold starts.`
|
||||
);
|
||||
|
||||
@@ -187,9 +187,7 @@ export async function runUpdateCommand(opts = {}) {
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
"\n [DRY RUN] Would run: npm install -g omniroute@latest --include=optional --legacy-peer-deps"
|
||||
);
|
||||
console.log("\n [DRY RUN] Would run: npm install -g omniroute@latest --include=optional");
|
||||
if (!skipBackup) console.log(" [DRY RUN] Would create backup in ~/.omniroute/backups/");
|
||||
return 0;
|
||||
}
|
||||
@@ -223,9 +221,7 @@ export async function runUpdateCommand(opts = {}) {
|
||||
const { execSync } = await import("child_process");
|
||||
// --include=optional keeps the optionalDependencies (better-sqlite3, keytar,
|
||||
// tls-client, llmlingua SLM stack) on update so an omit=optional config can't drop them.
|
||||
execSync("npm install -g omniroute@latest --include=optional --legacy-peer-deps", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
execSync("npm install -g omniroute@latest --include=optional", { stdio: "inherit" });
|
||||
// Trust-but-verify: `npm install -g` exits 0 even when a shadowing local install
|
||||
// (e.g. ~/node_modules/omniroute ahead of the global prefix on PATH) means the
|
||||
// binary the user actually runs was not touched. Re-read the running binary's
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, appendFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
import {
|
||||
RESTART_RESET_MS,
|
||||
DEFAULT_MAX_RESTARTS,
|
||||
@@ -21,13 +19,6 @@ import {
|
||||
|
||||
const CRASH_LOG_LINES = 50;
|
||||
|
||||
// #13538: shared path resolver so `omniroute doctor` (bin/cli/commands/doctor.mjs)
|
||||
// can surface the same file persistCrashLog() writes, without duplicating the
|
||||
// `<DATA_DIR>/server/...` convention from bin/cli/utils/pid.mjs.
|
||||
export function getCrashLogPath() {
|
||||
return join(resolveDataDir(), "server", "crash.log");
|
||||
}
|
||||
|
||||
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
// Bun needs the Node-compat polyfill preloaded (#9761). The file ships at the
|
||||
// package root via package.json "files" (see scripts/build/pack-artifact-policy.ts)
|
||||
@@ -90,10 +81,6 @@ export class ServerSupervisor {
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
// Tray mode has no visible console. Keep the supervised server hidden on Windows,
|
||||
// including when it is restarted after a crash. Without this, each supervised
|
||||
// spawn can create a visible terminal window.
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
writePidFile("server", this.child.pid);
|
||||
@@ -177,8 +164,7 @@ export class ServerSupervisor {
|
||||
if (aliveMs >= RESTART_RESET_MS) this.restartCount = 0;
|
||||
|
||||
if (this.restartCount >= this.maxRestarts) {
|
||||
const summary = `Server crashed ${this.maxRestarts} times in <30s.`;
|
||||
console.error(`\n⚠ ${summary}`);
|
||||
console.error(`\n⚠ Server crashed ${this.maxRestarts} times in <30s.`);
|
||||
if (this.onCrashCallback) {
|
||||
const action = this.onCrashCallback(this.crashLog);
|
||||
if (action === "disable-mitm-and-retry") {
|
||||
@@ -189,14 +175,6 @@ export class ServerSupervisor {
|
||||
}
|
||||
}
|
||||
this.dumpCrashLog();
|
||||
// #13538: the give-up path used to only console.error() this diagnostic.
|
||||
// In `--tray`/`--tray-worker` mode this process is launched detached with
|
||||
// stdio:"ignore" (bin/cli/tray/detachedTray.mjs buildTrayLaunch()), so
|
||||
// that console output is discarded by the OS and nothing ever explains
|
||||
// why the tray + gateway disappeared together. Best-effort persist a
|
||||
// durable record next to the existing per-service PID file convention
|
||||
// (bin/cli/utils/pid.mjs) so it survives the process exit below.
|
||||
this.persistCrashLog(summary);
|
||||
process.exit(exitCode ?? 1);
|
||||
return;
|
||||
}
|
||||
@@ -228,23 +206,6 @@ export class ServerSupervisor {
|
||||
console.error("--- End crash log ---\n");
|
||||
}
|
||||
|
||||
// #13538: best-effort append a durable crash record to
|
||||
// `<DATA_DIR>/server/crash.log`, mirroring the `<DATA_DIR>/<service>/.pid`
|
||||
// layout from bin/cli/utils/pid.mjs. Wrapped in try/catch — this diagnostic
|
||||
// write must NEVER block or fail shutdown (the give-up branch always calls
|
||||
// process.exit() right after this).
|
||||
persistCrashLog(summary) {
|
||||
try {
|
||||
const crashLogPath = getCrashLogPath();
|
||||
mkdirSync(dirname(crashLogPath), { recursive: true });
|
||||
const timestamp = new Date().toISOString();
|
||||
const body = [`[${timestamp}] ${summary}`, ...this.crashLog, ""].join("\n");
|
||||
appendFileSync(crashLogPath, body, "utf8");
|
||||
} catch {
|
||||
// Best-effort only — a diagnostic write failure must not prevent shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isShuttingDown = true;
|
||||
if (this.child?.pid) {
|
||||
|
||||
@@ -23,8 +23,8 @@ export function computeRestartDelayMs(restartCount) {
|
||||
return Math.min(1000 * 2 ** (Math.max(1, restartCount) - 1), 10_000);
|
||||
}
|
||||
|
||||
/** Resolve true when `host:port` can be bound right now. */
|
||||
function canBind(port, host) {
|
||||
/** Resolve true when nothing is listening on `port` (so a restart won't hit EADDRINUSE). */
|
||||
export function isPortFree(port, host = "127.0.0.1") {
|
||||
return new Promise((resolve) => {
|
||||
const tester = net.createServer();
|
||||
tester.once("error", (err) => {
|
||||
@@ -38,28 +38,6 @@ function canBind(port, host) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve true when nothing is listening on `port` (so a restart won't hit EADDRINUSE).
|
||||
*
|
||||
* Probing a single address is not enough. Node sets `SO_REUSEADDR` on every listener it
|
||||
* creates, and on macOS/BSD that lets a specific-address bind coexist with an existing
|
||||
* wildcard bind (and vice versa) — unlike Linux, which keeps rejecting the overlap in
|
||||
* LISTEN state. A gateway listening on `0.0.0.0:20128`, which is what `omniroute serve`
|
||||
* binds by default, was therefore reported as "free" by the old loopback-only probe: the
|
||||
* supervisor skipped its wait, the respawned child hit EADDRINUSE, and the crash loop
|
||||
* that #4425 set out to fix kept running. Probe every address the server may have bound.
|
||||
*
|
||||
* @param port Port to test.
|
||||
* @param host Additional address to test; wildcard and loopback are always included.
|
||||
* @returns False as soon as any candidate address is occupied.
|
||||
*/
|
||||
export async function isPortFree(port, host = "127.0.0.1") {
|
||||
for (const candidate of new Set([host, "0.0.0.0", "127.0.0.1"])) {
|
||||
if (!(await canBind(port, candidate))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* #4425: wait until `port` is free before respawning. After a crash the OS may not have
|
||||
* released the listen socket yet; restarting immediately produced the EADDRINUSE cascade
|
||||
|
||||
@@ -13,13 +13,11 @@ const LINUX_DESKTOP_NAME = "omniroute.desktop";
|
||||
function resolveCliPath() {
|
||||
const candidates = [];
|
||||
if (process.argv[1]) candidates.push(process.argv[1]);
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const which = execSync("command -v omniroute 2>/dev/null", { encoding: "utf8" }).trim();
|
||||
if (which) candidates.push(which);
|
||||
} catch {
|
||||
// command -v unavailable
|
||||
}
|
||||
try {
|
||||
const which = execSync("command -v omniroute 2>/dev/null", { encoding: "utf8" }).trim();
|
||||
if (which) candidates.push(which);
|
||||
} catch {
|
||||
// command -v unavailable
|
||||
}
|
||||
candidates.push(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "omniroute.mjs"));
|
||||
|
||||
|
||||
@@ -29,9 +29,7 @@ async function loadSystray2() {
|
||||
function getIconBase64() {
|
||||
// Icon ships at bin/cli/tray/icon.png — the previous "icons/icon.png" path
|
||||
// never existed, so the tray was created with an empty icon (#4605).
|
||||
// systray2 expects an ICO payload on Windows; the PNG asset is used elsewhere.
|
||||
// (ported from #13991, credit @prabhtheone)
|
||||
const iconPath = join(__dirname, process.platform === "win32" ? "icon.ico" : "icon.png");
|
||||
const iconPath = join(__dirname, "icon.png");
|
||||
if (existsSync(iconPath)) return readFileSync(iconPath).toString("base64");
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,86 +1,15 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
|
||||
// #13679 PR B: checked-in literal, used ONLY as a last-resort fallback (see
|
||||
// getActiveSalt() below) — /etc/machine-id is commonly world-readable, so relying on
|
||||
// this literal as the real default let any local user derive the same bearer token.
|
||||
const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1";
|
||||
const SALT_FILE_NAME = "cli-token-salt.json";
|
||||
const PERSISTED_SALT_RE = /^[0-9a-f]{64}$/;
|
||||
export const CLI_TOKEN_HEADER = "x-omniroute-cli-token";
|
||||
|
||||
let _cached = null;
|
||||
let _cachedSalt = null;
|
||||
let _cachedActiveSalt = null;
|
||||
|
||||
/** A `node --test` (or vitest) process that never opted into an explicit DATA_DIR must
|
||||
* not write a salt file into the operator's real home directory. Mirrors
|
||||
* dataPaths.ts::isTestContext() on the TS side. */
|
||||
function isTestContext() {
|
||||
return (
|
||||
process.env.NODE_ENV === "test" ||
|
||||
!!process.env.VITEST ||
|
||||
!!process.env.NODE_TEST_CONTEXT ||
|
||||
process.execArgv.includes("--test") ||
|
||||
process.argv.includes("--test")
|
||||
);
|
||||
}
|
||||
|
||||
function saltFilePath(dataDir) {
|
||||
return path.join(dataDir, SALT_FILE_NAME);
|
||||
}
|
||||
|
||||
function readPersistedSalt(filePath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
const salt = parsed && typeof parsed === "object" ? parsed.salt : undefined;
|
||||
if (typeof salt === "string" && PERSISTED_SALT_RE.test(salt)) return salt;
|
||||
} catch {
|
||||
// Missing, unreadable, or corrupt — fall through to (re)generation.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Mirrors establishPersistedSalt() in src/lib/machineToken.ts — same resolution
|
||||
* order, same salt file, same `wx`-flag create-race handling — so the CLI and the
|
||||
* server converge on the same bearer token (docs/security/CLI_TOKEN.md). */
|
||||
function establishPersistedSalt(dataDir) {
|
||||
const filePath = saltFilePath(dataDir);
|
||||
const existing = readPersistedSalt(filePath);
|
||||
if (existing) return existing;
|
||||
|
||||
const generated = crypto.randomBytes(32).toString("hex");
|
||||
try {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify({ salt: generated }), { flag: "wx", mode: 0o600 });
|
||||
return generated;
|
||||
} catch (err) {
|
||||
if (err && err.code === "EEXIST") return readPersistedSalt(filePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors getActiveSalt() in src/lib/machineToken.ts so a rotated
|
||||
* OMNIROUTE_CLI_SALT reaches the CLI too (docs/security/CLI_TOKEN.md). */
|
||||
function getActiveSalt() {
|
||||
const envSalt = process.env.OMNIROUTE_CLI_SALT;
|
||||
if (envSalt) return envSalt;
|
||||
|
||||
if (_cachedActiveSalt) return _cachedActiveSalt;
|
||||
|
||||
const hasExplicitDataDir = !!(process.env.DATA_DIR && process.env.DATA_DIR.trim());
|
||||
if (!hasExplicitDataDir && isTestContext()) {
|
||||
_cachedActiveSalt = BUILTIN_DEFAULT_SALT;
|
||||
return _cachedActiveSalt;
|
||||
}
|
||||
|
||||
const dataDir = resolveDataDir();
|
||||
const persisted = establishPersistedSalt(dataDir);
|
||||
_cachedActiveSalt = persisted || BUILTIN_DEFAULT_SALT;
|
||||
return _cachedActiveSalt;
|
||||
return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT;
|
||||
}
|
||||
|
||||
export function deriveCliToken(machineIdModule, salt) {
|
||||
@@ -90,7 +19,8 @@ export function deriveCliToken(machineIdModule, salt) {
|
||||
// undefined and calling it throws — which the catch below turned into an
|
||||
// empty token, silently disabling CLI auth for every management request.
|
||||
// Same resolution order as src/lib/machineToken.ts.
|
||||
const machineIdSync = machineIdModule?.machineIdSync || machineIdModule?.default?.machineIdSync;
|
||||
const machineIdSync =
|
||||
machineIdModule?.machineIdSync || machineIdModule?.default?.machineIdSync;
|
||||
if (typeof machineIdSync !== "function") return "";
|
||||
// machineIdSync(true) returns the original unhashed hardware ID — mirrors
|
||||
// getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening).
|
||||
|
||||
@@ -59,81 +59,10 @@ export function isPidRunning(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
// A port that is already owned must be reported, not spawned into. `omniroute
|
||||
// serve` used to hand the conflict to the child, which died with EADDRINUSE
|
||||
// twice on the supervisor's restart budget and printed three raw Node stack
|
||||
// traces without ever saying another instance owned the port. It did that
|
||||
// AFTER writing the pid files, so the doomed second instance de-registered the
|
||||
// healthy running one (supervisor/.pid left pointing at the dead starter,
|
||||
// server/.pid deleted outright).
|
||||
//
|
||||
// Discovery mirrors killByPort() in bin/cli/commands/stop.mjs (netstat on
|
||||
// win32, lsof elsewhere); the two are worth consolidating next time stop.mjs
|
||||
// is touched.
|
||||
export async function findListeningPids(port, deps = {}) {
|
||||
const platform = deps.platform || process.platform;
|
||||
let exec = deps.execFileAsync;
|
||||
if (!exec) {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
exec = promisify(execFile);
|
||||
}
|
||||
try {
|
||||
if (platform === "win32") {
|
||||
const { stdout } = await exec("netstat", ["-ano"]);
|
||||
return parseNetstatListeningPids(stdout, port);
|
||||
}
|
||||
const { stdout } = await exec("lsof", ["-ti", `:${port}`]);
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((entry) => parseInt(entry, 10))
|
||||
.filter((entry) => Number.isFinite(entry) && entry > 0);
|
||||
} catch {
|
||||
// No netstat/lsof available, or simply no listener. Report "free": a false
|
||||
// "busy" would block a legitimate start, the worse failure of the two.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseNetstatListeningPids(stdout, port) {
|
||||
const portCol = `:${port}`;
|
||||
const pids = [];
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const cols = line.trim().split(/\s+/);
|
||||
// Proto LocalAddress ForeignAddress State PID
|
||||
if (cols.length < 5) continue;
|
||||
if (cols[0] !== "TCP" && cols[0] !== "TCPv6") continue;
|
||||
if (!(cols[1] || "").endsWith(portCol)) continue;
|
||||
if ((cols[cols.length - 2] || "").toUpperCase() !== "LISTENING") continue;
|
||||
const pid = parseInt(cols[cols.length - 1], 10);
|
||||
if (Number.isFinite(pid) && pid > 0 && !pids.includes(pid)) pids.push(pid);
|
||||
}
|
||||
return pids;
|
||||
}
|
||||
|
||||
export function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// A probe that times out is classified "hanging" and never counts toward
|
||||
// readiness (#6800), so a FIXED per-probe timeout puts a hard ceiling on how
|
||||
// slow a healthy first response is allowed to be. On a cold Windows boot the
|
||||
// health route resolves ~10 dynamic imports and reads the DB before it can
|
||||
// answer; when that first response lands past the ceiling the poll can never
|
||||
// succeed, because each abort discards the in-flight request before the route
|
||||
// finishes (its own 1s payload cache is never populated either) and the next
|
||||
// probe restarts the same work into the same ceiling — for the whole budget.
|
||||
// The CLI then printed "⚠ Server did not respond within 60s" over a server
|
||||
// that went on to serve traffic normally. Escalating the timeout keeps #6800's
|
||||
// guarantee (a socket that never answers still yields "hanging" forever) while
|
||||
// letting a slow-but-real response actually be observed.
|
||||
const INITIAL_PROBE_TIMEOUT_MS = 2000;
|
||||
const MAX_PROBE_TIMEOUT_MS = 15000;
|
||||
// Floor for the last probe of a budget that is nearly spent — long enough for a
|
||||
// loopback round-trip, short enough not to overrun the caller's timeout.
|
||||
const MIN_PROBE_TIMEOUT_MS = 250;
|
||||
|
||||
// #2460: Default raised from 15s to 60s so Windows users (slower Next.js
|
||||
// cold start due to filesystem watchers, antivirus, etc.) get a working
|
||||
// "server ready" signal instead of a phantom timeout while the server is
|
||||
@@ -154,24 +83,18 @@ export function resolveReadyTimeoutMs(overrides = {}) {
|
||||
if (typeof overrides.timeoutMs === "number" && overrides.timeoutMs > 0) {
|
||||
return overrides.timeoutMs;
|
||||
}
|
||||
const envValue = Number.parseInt(process.env.OMNIROUTE_READY_TIMEOUT_MS || "", 10);
|
||||
const envValue = Number.parseInt(
|
||||
process.env.OMNIROUTE_READY_TIMEOUT_MS || "",
|
||||
10
|
||||
);
|
||||
return Number.isFinite(envValue) && envValue > 0 ? envValue : DEFAULT_READY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// `onOutcome` receives every probe classification so a caller can tell a
|
||||
// "nothing ever bound the port" timeout apart from a "port is up, the health
|
||||
// route is just still warming" one when it reports the failure.
|
||||
export async function waitForServer(port, timeout = 60000, { onOutcome } = {}) {
|
||||
export async function waitForServer(port, timeout = 60000) {
|
||||
const start = Date.now();
|
||||
let tcpListeningSince = null;
|
||||
let probeTimeout = INITIAL_PROBE_TIMEOUT_MS;
|
||||
while (Date.now() - start < timeout) {
|
||||
const remaining = timeout - (Date.now() - start);
|
||||
const outcome = await pollHealthOnce(
|
||||
port,
|
||||
Math.max(MIN_PROBE_TIMEOUT_MS, Math.min(probeTimeout, remaining))
|
||||
);
|
||||
onOutcome?.(outcome);
|
||||
const outcome = await pollHealthOnce(port);
|
||||
if (outcome === "ready") return true;
|
||||
if (outcome === "fast-reject") {
|
||||
if (tcpListeningSince === null) tcpListeningSince = Date.now();
|
||||
@@ -180,11 +103,6 @@ export async function waitForServer(port, timeout = 60000, { onOutcome } = {}) {
|
||||
// "hanging" (request timed out with no response at all) or
|
||||
// "not-listening" — neither counts toward the grace window.
|
||||
tcpListeningSince = null;
|
||||
// Only a hang says "this server may simply need longer to answer";
|
||||
// widen the next probe instead of aborting into the same ceiling again.
|
||||
if (outcome === "hanging") {
|
||||
probeTimeout = Math.min(probeTimeout * 2, MAX_PROBE_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
@@ -197,13 +115,11 @@ export async function waitForServer(port, timeout = 60000, { onOutcome } = {}) {
|
||||
// actively refused/reset (not a timeout) — the HTTP server is alive and
|
||||
// answering quickly, just not routing this endpoint yet (#2460).
|
||||
// - "hanging": the request timed out waiting for any response — the
|
||||
// process accepted the TCP connection but never answered (#6800). The
|
||||
// caller widens `probeTimeoutMs` after a hang so a merely slow (rather
|
||||
// than dead) server is not aborted into the same ceiling on every probe.
|
||||
// process accepted the TCP connection but never answered (#6800).
|
||||
// - "not-listening": nothing is accepting connections on the port at all.
|
||||
// #11766: probe both IPv4 and IPv6 loopback to handle servers listening on
|
||||
// either family (or both).
|
||||
async function pollHealthOnce(port, probeTimeoutMs = INITIAL_PROBE_TIMEOUT_MS) {
|
||||
async function pollHealthOnce(port) {
|
||||
const hosts = ["127.0.0.1", "::1"];
|
||||
const outcomes = [];
|
||||
|
||||
@@ -212,7 +128,7 @@ async function pollHealthOnce(port, probeTimeoutMs = INITIAL_PROBE_TIMEOUT_MS) {
|
||||
hosts.map(async (host) => {
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(probeTimeoutMs),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
return { host, outcome: res.ok ? "ready" : "fast-reject" };
|
||||
} catch (err) {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(providers):** add Lyceum (lyceum.technology) as an OpenAI-compatible, pay-per-use provider — chat, embeddings, and live `/models` discovery through `https://api.lyceum.technology/openai/v1`, plus a credit-balance quota fetcher (`GET /api/v2/external/billing/credits`) surfaced in Dashboard › Limits and quota-aware preflight ([#12470](https://github.com/diegosouzapw/OmniRoute/pull/12470)).
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(gamification): pay the documented `streak_bonus` and `badge_unlock` XP rewards.** `XP_REWARDS` listed both rewards but the award pipeline never paid them: the private reward table in `events.ts` omitted them, `updateStreak()` did not report when a streak extended, and badge unlocks carried no XP. Every request that extends a daily streak now pays `streak_bonus × streak length` once per UTC day (guarded by a same-day `xp_audit_log` check), and every badge unlocked through the pipeline pays `badge_unlock` once per badge (guarded by the `user_badges` primary key; `unlockBadge()` now reports whether it inserted). Bonus XP flows through the same `addXp` + level sync + global/weekly/monthly leaderboard path as action XP, so level-ups and rankings include it. The Radar supporter recognition unlock stays XP-free. (#12522 — thanks @pacocartones)
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(providers):** advertise a `free-tier` capability in the provider plugin manifest for every provider with documented free models, so sidecars and dashboards can filter free-capable providers without reading the quota catalog ([#12786](https://github.com/diegosouzapw/OmniRoute/pull/12786)) — thanks @maxmad64bis
|
||||
1
changelog.d/features/12985-eurouter-provider.md
Normal file
1
changelog.d/features/12985-eurouter-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** Added EURouter as an OpenAI-compatible API-key gateway (`https://api.eurouter.ai/v1`), with live model discovery via `passthroughModels`. Its copy states that models are served by third-party upstreams listed per model, so an EU-based router is not read as EU data residency for inference.
|
||||
1
changelog.d/features/12986-greenpt-provider.md
Normal file
1
changelog.d/features/12986-greenpt-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** Added GreenPT as an OpenAI-compatible API-key provider (`https://api.greenpt.ai/v1`), with live model discovery via `passthroughModels`. No free-inference badge: the published docs describe a free API subscription billed per token, not a free tier.
|
||||
1
changelog.d/features/13044-i18n-batch-eu.md
Normal file
1
changelog.d/features/13044-i18n-batch-eu.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(i18n):** 9 new locales — Greek (`el`), Croatian (`hr`), Serbian (`sr`), Lithuanian (`lt`), Estonian (`et`), Latvian (`lv`), Slovenian (`sl`), Maltese (`mt`), Irish (`ga`) — across the dashboard, docs mirrors, CLI, README and the site; OmniRoute now ships every official EU language (51 locales). (#13044)
|
||||
1
changelog.d/features/13578-proxy-skip-recently-failed.md
Normal file
1
changelog.d/features/13578-proxy-skip-recently-failed.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxies):** proxy pools and opencode's per-account rotation stop re-serving a proxy that just failed (refused TCP probe, or a 429 through it) for a period that doubles on each repeat up to a cap, without writing any proxy status; with every candidate set aside the choice is unchanged. Opt-in via the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: selection unchanged) ([#13578](https://github.com/diegosouzapw/OmniRoute/pull/13578)) — thanks @maxmad64bis
|
||||
1
changelog.d/features/13580-proxy-log-upstream-status.md
Normal file
1
changelog.d/features/13580-proxy-log-upstream-status.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxy-logs):** proxy log rows keep the HTTP status the provider actually returned (`upstream_status`, null when no response arrived), so a throttled egress IP (429), a refused one (403) and a provider outage (500) are no longer the same "error" line, and a 429 generated locally is no longer mistaken for one from the provider ([#13580](https://github.com/diegosouzapw/OmniRoute/pull/13580)) — thanks @maxmad64bis
|
||||
1
changelog.d/features/13581-pool-egress-observation.md
Normal file
1
changelog.d/features/13581-pool-egress-observation.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxies):** the proxy pool editor shows, for the last 24 h, how many distinct egress IPs actually served the pool's members, how many connections went through them and the most seen behind one IP, read from the proxy log through a separate route so it can never break the pool screen; opt-in with the `PROXY_POOL_EGRESS_OBSERVATION` feature flag (default off) ([#13581](https://github.com/diegosouzapw/OmniRoute/pull/13581)) — thanks @maxmad64bis
|
||||
1
changelog.d/features/13602-pool-skips-refused-member.md
Normal file
1
changelog.d/features/13602-pool-skips-refused-member.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxies):** a proxy pool stops re-serving a member the provider just refused through it and tries another member instead, reusing the existing skip cooldown; a later success through the member clears it. Opt-in with the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: pool selection unchanged) ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602)) — thanks @maxmad64bis
|
||||
1
changelog.d/features/13660-i18n-batch-sa.md
Normal file
1
changelog.d/features/13660-i18n-batch-sa.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(i18n):** 8 new locales — Kannada (`kn`), Malayalam (`ml`), Odia (`or`), Punjabi (`pa`), Nepali (`ne`), Sinhala (`si`), Burmese (`my`), Khmer (`km`) — across the dashboard, docs mirrors, CLI, README and the site (59 locales). The translator now restores the ICU literal escape around angle placeholders and splits oversized docs sections before translating. (#0000)
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(flags):** add `DB_HEALTHCHECK_STARTUP_DEFERRED_ENABLED` (default off) — opt-in deferral of the startup DB health/integrity check past process boot via `setImmediate`; off keeps the pre-#13717 behavior of blocking `getDbInstance()` until the check has already run (#13717).
|
||||
1
changelog.d/features/13727-i18n-batch-af.md
Normal file
1
changelog.d/features/13727-i18n-batch-af.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(i18n):** 7 new locales — Hausa (`ha`), Yoruba (`yo`), Igbo (`ig`), Amharic (`am`), Uzbek (`uz`), Georgian (`ka`), Armenian (`hy`) — across the dashboard, docs mirrors, CLI, README and the site (66 locales, the full planned expansion from 43). (#13727)
|
||||
1
changelog.d/features/13886-site-retranslate.md
Normal file
1
changelog.d/features/13886-site-retranslate.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(i18n):** `retranslate-site` rewrites the site catalogs' verbatim-English leaves (2,059 across 63 catalogs; mean English residue 10.3 % → 6.3 %, the rest being brand names kept on purpose). (#13886)
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(kiro):** expose Kiro's provider-native Opus 5 Max effort tier — `<base>-max` in the Claude effort catalog, `max` in the Kiro effort values, and the adaptive-thinking envelope for `claude-opus-5` ([#14284](https://github.com/diegosouzapw/OmniRoute/pull/14284)) — original change by tarciorick, thanks @bufftop25
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(sse):** forward the auto mode classifier beta (`dangerous-tool-use-2026-09-03`) to Anthropic-format upstreams, and let `anthropic-compatible-*` providers forward client-negotiated betas at all, so Claude Code sessions behind the gateway stay eligible for server-side auto mode classification ([#14312](https://github.com/diegosouzapw/OmniRoute/pull/14312)) — thanks @dpozimski
|
||||
1
changelog.d/features/agnes-30-flash-catalog.md
Normal file
1
changelog.d/features/agnes-30-flash-catalog.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): **list Agnes 3.0 Flash as the current free chat model, drop retired 1.5 Flash, add Image 2.0/2.5 Flash plus Video 2.5/2.5 Flash, and discover the live `/v1/models` catalog (including the CN host `api.agnes-ai.cn`).** `agnes-1.5-flash` now forwards to `agnes-3.0-flash`. Video 2.5 polls `GET /v1/videos/{id}` (not the V2.0 `/agnesapi` contract). Live `/v1/models` (2026-09-09) no longer serves 1.5; the wiki marks it deprecated. 3.0 Flash is 512K context / 65,536 max output, same window as 2.5. CN-region keys use the existing per-connection base-URL field, default stays `apihub.agnes-ai.com`.
|
||||
1
changelog.d/features/agnes-cn-provider.md
Normal file
1
changelog.d/features/agnes-cn-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): **Added Agnes AI (China) as `agnes-cn` pointed at `https://api.agnes-ai.cn/v1`. Keys issued for `apihub.agnes-ai.com` stay on the existing `agnes` card. Live `/v1/models` on that host lists `agnes-3.0-flash` (same id as intl); the CN seed matches 2.0/2.5/3.0 and not retired 1.5.**
|
||||
1
changelog.d/features/codex-gpt-6-astra.md
Normal file
1
changelog.d/features/codex-gpt-6-astra.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(sse):** Codex and OpenAI catalogs list GPT-6 Astra with effort aliases (`-low` through `-ultra`); Codex CLI identity pins `@openai/codex@0.153.4` in lockstep with the image ([#13026](https://github.com/diegosouzapw/OmniRoute/pull/13026))
|
||||
11
changelog.d/features/orchestration-compare-runs.md
Normal file
11
changelog.d/features/orchestration-compare-runs.md
Normal file
@@ -0,0 +1,11 @@
|
||||
- **feat(dashboard):** the orchestration History tab gained a "Compare runs" mode — toggling it
|
||||
turns each grid cell into a 2-item selection queue (a 3rd click drops the oldest pick), and
|
||||
picking two cells opens a side-by-side comparison panel instead of the usual detail drawer.
|
||||
The panel fetches both runs' detail the same way the drawer does (falling back to persisted
|
||||
history once a run leaves the live TTL window) and shows, per side: identity/source/state,
|
||||
start time, a signed `right - left` delta for duration/cost/event count, the event timeline
|
||||
aligned by index, and any memory hits. One side's fetch failing never blocks the other, and a
|
||||
delta is only ever computed when both sides have a finite value — otherwise it renders "—",
|
||||
never `NaN`. Comparing two runs from different sources or skills still works; a banner marks
|
||||
the deltas as informational rather than hiding them, since the two runs aren't a strict
|
||||
apples-to-apples pair.
|
||||
1
changelog.d/features/xai-oauth-live-models.md
Normal file
1
changelog.d/features/xai-oauth-live-models.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** share the existing `api.x.ai/v1/models` discovery config with `xai-oauth` so SuperGrok OAuth connections pick up new Grok ids without a registry seed edit.
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(video):** Honor `poll_interval_ms` and `max_polls` for Agnes and other video job providers so client-requested polling delays prevent upstream status-query rate limits.
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(api):** `/v1/files` and `/v1/batches` now enforce one ownership rule everywhere — a dashboard session is the instance operator, an API key acts on its own records only, and a record with no owner is denied to every non-session caller. Previously a file or batch whose `api_key_id` was null (a dashboard-session or anonymous upload, or a batch artifact inheriting one) could be read, downloaded, deleted, cancelled or used as a batch input by any other key or by an unauthenticated caller (GHSA-2jm2-mpx8-6523), and `GET /v1/files` / `GET /v1/batches` returned every tenant's records to an anonymous or invalid-bearer caller under the default `REQUIRE_API_KEY=false` (GHSA-m3hp-hq9g-fpmv) — both lists now fail closed with a `401`, and only a dashboard session without a key reads the whole instance. The same shared rule lets the dashboard cancel any batch, not just unowned ones. Behaviour change: the anonymous upload → batch → download flow no longer works without an API key, since a null owner cannot be attributed. Subsumes [#13683](https://github.com/diegosouzapw/OmniRoute/pull/13683) — thanks @hartmark
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(auth):** closed the JWT_SECRET bootstrap chain (GHSA-7pq4-8pvv-rx7r). The fresh-install bootstrap gate in `isAuthRequired()` now decides "loopback" from the trusted peer — the token-stamped real TCP peer the custom server writes, the pipeline's own locality verdict, or a real socket — and never from the client-controlled `Host` / `nextUrl.hostname` whenever a stamping server is in front (every supported runtime), so `Host: localhost` from a remote address no longer opens the window; the anonymous first-password write (`POST /api/settings/require-login`) is under the same loopback constraint instead of being open to every network peer, and `managementPolicy` hands its `peerContext` verdict down explicitly. `/api/settings/obsidian` (incl. `/webdav`, which mints reusable WebDAV Basic credentials for a caller-chosen root served before Next.js) joined `ALWAYS_PROTECTED_API_PATHS`, and `enableObsidianVaultSync()` refuses a vault that is, sits inside, or contains the data directory (realpath-resolved), so the WebDAV file service can no longer be pointed at `server.env` / `storage.sqlite`
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(release):** the local merge-train now runs the four i18n contract gates on every combined tree before an `--admin` merge — `i18n:check-keys` (every `en.json` key in all 65 dashboard catalogs), `i18n:check-keys:cli`, `i18n:check-ratio` (real-translation ratio) and the docs drift gate (`check-translation-drift.mjs`) — so a PR that adds dashboard keys without translations or edits a documented page without its mirrors is ejected from the train instead of landing on the release tip. Three such landings reached `release/v3.8.51` in 48 h (#13670, `1b2349de`, `7f1b4a5e`) although each PR's own CI was red on the same gates, because the train validated only typecheck, size/complexity and changelog integrity.
|
||||
1
changelog.d/fixes/0000-responses-node-model-test.md
Normal file
1
changelog.d/fixes/0000-responses-node-model-test.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** model health tests for a provider node set to the Responses API now call `/v1/responses` with a Responses-shaped body instead of `/v1/chat/completions` — those models were reported as `Provider returned HTTP 200 but no text content` even though the same model answered normally through `/v1/responses` ([#13070](https://github.com/diegosouzapw/OmniRoute/issues/13070))
|
||||
@@ -0,0 +1 @@
|
||||
- fix(routing): stop a round-robin combo's "opencode" targets from collapsing onto the opencode-zen connection (#11912)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(routing): stop the reactive-compaction debug log from lying when compression is globally disabled (#11977)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(electron): relativize standalone-bundle symlink targets so Stage 8 manifest verification stops failing on Windows (#11979)
|
||||
1
changelog.d/fixes/12061-compression-studio-run-error.md
Normal file
1
changelog.d/fixes/12061-compression-studio-run-error.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): surface a visible error when Compression Studio's combined preview run fails (#12061)
|
||||
1
changelog.d/fixes/12063-compression-profile-header.md
Normal file
1
changelog.d/fixes/12063-compression-profile-header.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): make the compression "Effective pipeline" preview honor the active profile and warn when the master switch is off (#12063)
|
||||
1
changelog.d/fixes/12072-tinycms-dom-shim-leak.md
Normal file
1
changelog.d/fixes/12072-tinycms-dom-shim-leak.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): scope TinyCMS Web signer's DOM shims to each call instead of leaking them for the process lifetime, and surface a clean HTTP status on a non-JSON interception-toggles error (#12072)
|
||||
1
changelog.d/fixes/12111-vision-bridge-model-lockout.md
Normal file
1
changelog.d/fixes/12111-vision-bridge-model-lockout.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(guardrails): stop Vision Bridge from re-selecting a model locked after a 404 (#12111)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(sse): require Responses-shaped body before native OpenAI-compatible passthrough (#12129)
|
||||
1
changelog.d/fixes/12132-minimax-m3-adaptive-thinking.md
Normal file
1
changelog.d/fixes/12132-minimax-m3-adaptive-thinking.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): minimax-m3 now collapses manual thinking.type:"enabled" to adaptive, preventing upstream 400 (2013) (#12132)
|
||||
1
changelog.d/fixes/12172-model-id-collision-chat-image.md
Normal file
1
changelog.d/fixes/12172-model-id-collision-chat-image.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): scope model visibility overrides by modality so hiding a Chat model no longer hides an identically-ID'd Image/Embeddings/etc. model (#12172)
|
||||
1
changelog.d/fixes/12173-lmstudio-multi-account.md
Normal file
1
changelog.d/fixes/12173-lmstudio-multi-account.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): scope local-provider apiKey dedup to matching base URL so LM Studio/Ollama-style connections support multiple accounts (#12173)
|
||||
1
changelog.d/fixes/12190-trae-referer-401.md
Normal file
1
changelog.d/fixes/12190-trae-referer-401.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): refresh Trae's stale Referer/Origin and forward user timezone so imported connections stop failing with 401 (#12190)
|
||||
1
changelog.d/fixes/12196-opencode-go-gpt56luna.md
Normal file
1
changelog.d/fixes/12196-opencode-go-gpt56luna.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): route opencode-go/gpt-5.6-luna to /responses instead of /chat/completions (#12196)
|
||||
1
changelog.d/fixes/12251-extra-upstream-headers-delete.md
Normal file
1
changelog.d/fixes/12251-extra-upstream-headers-delete.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): allow deleting the last extra-upstream-header row even when invalid (#12251)
|
||||
1
changelog.d/fixes/12272-missing-i18n.md
Normal file
1
changelog.d/fixes/12272-missing-i18n.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** translate pre-existing `__MISSING__:` keys for `combo.sort`, `requestLogger.detail` expand/collapse, `common.profile`, and `settings.resilienceCredentialHealth*` across 39 locales ([#12272](https://github.com/diegosouzapw/OmniRoute/issues/12272))
|
||||
1
changelog.d/fixes/12296-node-runtime-guard-early.md
Normal file
1
changelog.d/fixes/12296-node-runtime-guard-early.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cli): run the Node.js runtime compatibility guard before the heavy `tsx/esm` + Commander import chain so an unsupported runtime gets a clear message instead of a raw `Invalid regular expression flags` crash (#12296)
|
||||
1
changelog.d/fixes/12298-provider-node-delete-refresh.md
Normal file
1
changelog.d/fixes/12298-provider-node-delete-refresh.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): refresh the providers list after deleting a compatible provider node (#12298)
|
||||
1
changelog.d/fixes/12341-budget-alias-auto.md
Normal file
1
changelog.d/fixes/12341-budget-alias-auto.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(usage): fail closed on API-key budget enforcement when a provider's `auto` routing alias has no pricing row, instead of silently counting it as $0 (#12341)
|
||||
1
changelog.d/fixes/12356-agnes-video-poll-model-name.md
Normal file
1
changelog.d/fixes/12356-agnes-video-poll-model-name.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(providers):** include the submitted Agnes video model when polling by `video_id`
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(routing):** custom OpenAI-compatible nodes now honor the saved Chat/Responses API type after edits instead of letting the node's original ID prefix override the live connection setting ([#11884](https://github.com/diegosouzapw/OmniRoute/issues/11884)).
|
||||
1
changelog.d/fixes/12370-responses-function-call-name.md
Normal file
1
changelog.d/fixes/12370-responses-function-call-name.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): restore the `name` field on non-streaming `/v1/responses` `function_call` output items — a plain (non-namespace) tool call's identity restore was blindly applying the `_toolNameMap` alias-table fallback as a `{namespace, name}` object, silently blanking `name` to `undefined` (dropped entirely by JSON.stringify) and leaving Codex unable to dispatch the call, so it re-narrated its intent in a loop instead (#12370)
|
||||
1
changelog.d/fixes/12398-claude-truly-empty-stream.md
Normal file
1
changelog.d/fixes/12398-claude-truly-empty-stream.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): surface an error instead of a silent empty 200 when a Claude stream closes with zero bytes (#12398)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(oauth): warn before the dead localhost:8080 redirect in antigravity/gemini `oauth start` (#12413)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(db):** give `conversation_turn_nodes` its own independent retention knob (`retention.conversationTurnNodes`, default 30 days — matching `callLogs` so upgrading changes nothing until an operator overrides it) instead of sharing `callLogs`, and sweep orphaned `agentic_conversations` after the nodes expire (#12453).
|
||||
1
changelog.d/fixes/12491-codex-wreq-standalone-runtime.md
Normal file
1
changelog.d/fixes/12491-codex-wreq-standalone-runtime.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(sse):** Codex WebSocket transport (including the app-server) no longer fails to load in the Next.js standalone Docker runtime — the wreq-js loader now resolves its module name dynamically instead of a literal Turbopack could rewrite to an unreachable build-time symlink (#12491) — thanks @marshalfevzi
|
||||
1
changelog.d/fixes/12517-devin-cli-sse-double-close.md
Normal file
1
changelog.d/fixes/12517-devin-cli-sse-double-close.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): stop devin-cli spawn error from double-closing the SSE controller (#12517)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(rerank):** clamp Voyage `top_k` to the documents actually sent after empty-string filtering, and honor `return_documents: false` in the NVIDIA response adapter (#12523 — thanks @pacocartones)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(audio):** `/v1/audio/translations` now resolves combo names the way `/v1/audio/transcriptions` already does, so a combo that `GET /v1/models` advertises is fanned out to its targets instead of being rejected with `400 Invalid translation model: <combo>. Use format: provider/model`; literal `provider/model` ids and unknown bare names behave as before (#12536 — thanks @pacocartones)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(gemini):** strip the JSON-Schema-2020-12 `prefixItems` keyword from Gemini tool schemas at every nesting level, so Claude Code tool definitions no longer fail with `400 Unknown name "prefixItems"` on Gemini models (#12540 — thanks @pacocartones)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(api-manager):** Expose an accessible loading status while API keys are fetched instead of an empty accessibility tree (#12541 — thanks @pacocartones)
|
||||
1
changelog.d/fixes/12543-video-frame-estimate-clamp.md
Normal file
1
changelog.d/fixes/12543-video-frame-estimate-clamp.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(video):** Clamp `estimateJpegFrameBytes` at zero for padding-only payloads and build the three encode-side frame data URIs from `JPEG_FRAME_DATA_URI_PREFIX` instead of a repeated literal (#12543 — thanks @pacocartones)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(devin):** accept Windows `DEVIN_AGENTIC_HOME` sandbox paths (`C:\...\.sandbox\...`) in the isolated-home check so the Devin Claude Bridge no longer fails closed on Windows ([#12405](https://github.com/diegosouzapw/OmniRoute/issues/12405)) (#12545 — thanks @pacocartones)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(db):** Add `conversation_turn_nodes` and orphaned `agentic_conversations` to the auto-cleanup cycle under the existing `retention.callLogs` window, so identity nodes whose call-log content has already been purged no longer accumulate without bound in `storage.sqlite` (#12548 — thanks @pacocartones)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** Wrap the `~/.claude/profiles/<name>/settings.json` placeholder in ICU single quotes in the `OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES` feature-flag description across all 42 locales and the TypeScript default, so next-intl no longer fails with `INVALID_MESSAGE: UNCLOSED_TAG` and the Feature Flags card shows the description instead of the raw key (#12549 — thanks @pacocartones)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(orchestration):** `updateCloudAgentTask` now publishes the task's real `status` on `agent.task.updated` when an update only touches `result`, `activities` or `error`, instead of the fabricated `"updated"` state, and stays silent when no row matched the id (#12550 — thanks @pacocartones)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(i18n):** the home "Recent Requests" panel and the Provider Topology legend are now translated instead of rendering English copies on non-English dashboards; the legend reads its own `home.topologyLegend*` labels with consistent casing rather than borrowing the memory-settings "Recent" and analytics "Error" strings (#12551 — thanks @pacocartones).
|
||||
1
changelog.d/fixes/12552-feature-flags-reference-sync.md
Normal file
1
changelog.d/fixes/12552-feature-flags-reference-sync.md
Normal file
@@ -0,0 +1 @@
|
||||
- **docs(reference):** bring the `FEATURE_FLAGS.md` catalog back to 1:1 with `featureFlagDefinitions.ts` — 20 missing flags added, the two `*_BLOCK_THRESHOLD` env-only knobs moved out of the flag tables, category/total counts and the Live WS port corrected, guarded by a static test (#12552 — thanks @pacocartones)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user