mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Compare commits
158 Commits
release/v3
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
182bc398df | ||
|
|
06882d6a89 | ||
|
|
db9724d29c | ||
|
|
2d39c95285 | ||
|
|
50df2ca42e | ||
|
|
8c1895574b | ||
|
|
d40200a26b | ||
|
|
3eb9ff29e8 | ||
|
|
325188eb9b | ||
|
|
09d0d11ed5 | ||
|
|
515bc3465f | ||
|
|
917b2244b6 | ||
|
|
af32e0041c | ||
|
|
0412b128c0 | ||
|
|
083d4b0044 | ||
|
|
bf6c8ca3fd | ||
|
|
d38bf09551 | ||
|
|
568472d09f | ||
|
|
d648e215f8 | ||
|
|
1f685ebcd7 | ||
|
|
028ed4ea7b | ||
|
|
a9ddef1302 | ||
|
|
195e40ff7a | ||
|
|
a58183ab84 | ||
|
|
d3e02e630f | ||
|
|
b03134e709 | ||
|
|
678cbcf207 | ||
|
|
072094000d | ||
|
|
cd27701c1e | ||
|
|
d4c2e4dd0a | ||
|
|
3d37242993 | ||
|
|
d575691d90 | ||
|
|
cca71a5d22 | ||
|
|
8a7cf50144 | ||
|
|
07b385d4c1 | ||
|
|
6137d4ef57 | ||
|
|
d65050a1db | ||
|
|
7c75b48792 | ||
|
|
950fc6e60e | ||
|
|
4a1ae868ce | ||
|
|
6e28889aad | ||
|
|
cc8557cedf | ||
|
|
b1b3069cd1 | ||
|
|
de19b3b087 | ||
|
|
565b77143c | ||
|
|
ca72de7ba4 | ||
|
|
5073aff2ae | ||
|
|
ca0fdddebf | ||
|
|
f1589801ee | ||
|
|
419a90f240 | ||
|
|
537e5b5f6e | ||
|
|
9708288b57 | ||
|
|
833adce8fb | ||
|
|
6035fd8ba1 | ||
|
|
1cb246215a | ||
|
|
21ce4872d2 | ||
|
|
c872b6710d | ||
|
|
b8e3dc950c | ||
|
|
15ee83b44a | ||
|
|
0811a15a09 | ||
|
|
98296169ed | ||
|
|
118875e3ed | ||
|
|
9d5954122d | ||
|
|
5d9ecf6f8b | ||
|
|
8080800d8f | ||
|
|
078785cbf3 | ||
|
|
1a1a51c744 | ||
|
|
8251e7b8fb | ||
|
|
7a6f43209c | ||
|
|
413d1d3827 | ||
|
|
db192ce7c6 | ||
|
|
ec0572d060 | ||
|
|
95e26a07ff | ||
|
|
9b07e0ea59 | ||
|
|
f9ccf342c0 | ||
|
|
70afa2b5c6 | ||
|
|
4305ef8690 | ||
|
|
27c994ffd3 | ||
|
|
5c0cda929c | ||
|
|
2039095f69 | ||
|
|
c522454c79 | ||
|
|
2a7f2c892a | ||
|
|
eb175db8ed | ||
|
|
4d61198e1a | ||
|
|
86b14add6e | ||
|
|
108e93e14b | ||
|
|
459ffcf2c0 | ||
|
|
e99e2887bf | ||
|
|
604654cc3a | ||
|
|
e1ffc8e850 | ||
|
|
805519befb | ||
|
|
9a5627a4a6 | ||
|
|
21aa5617cf | ||
|
|
dad55fed0a | ||
|
|
b5995778cb | ||
|
|
cc8e1bedf5 | ||
|
|
9244d930ea | ||
|
|
396a9a72cd | ||
|
|
122f6d3a19 | ||
|
|
2d09f72b29 | ||
|
|
6fe21e59e9 | ||
|
|
c02aa7b064 | ||
|
|
57de29a415 | ||
|
|
b92f045f70 | ||
|
|
486f01710c | ||
|
|
ec1c5df091 | ||
|
|
306863dd9e | ||
|
|
69703ee7db | ||
|
|
be2ab9e419 | ||
|
|
607ad12e84 | ||
|
|
32b2df974a | ||
|
|
e176774395 | ||
|
|
983e152ea2 | ||
|
|
1ad6c46b46 | ||
|
|
d2aa67a11c | ||
|
|
f4fa983a9f | ||
|
|
35a3962cf0 | ||
|
|
a9368af7df | ||
|
|
2a90a9e589 | ||
|
|
ea406b2197 | ||
|
|
fc2fdc7e3c | ||
|
|
fcf162d3fe | ||
|
|
502f43c283 | ||
|
|
c922b21102 | ||
|
|
7f5b1e4d83 | ||
|
|
5bffefb489 | ||
|
|
2cd5609628 | ||
|
|
e87d35ceda | ||
|
|
c69bd6a4eb | ||
|
|
867e9043f5 | ||
|
|
1244e4903b | ||
|
|
5c0b29e2b3 | ||
|
|
e85fb9bb5d | ||
|
|
eb1920bf91 | ||
|
|
812f2f20a1 | ||
|
|
34b510a543 | ||
|
|
e420c34fce | ||
|
|
e494afebcf | ||
|
|
8d50c9de16 | ||
|
|
cadc3f10b7 | ||
|
|
19d91d82e2 | ||
|
|
ee24eb52d4 | ||
|
|
bfaf459f3c | ||
|
|
d0396c200d | ||
|
|
3b2a2f02a9 | ||
|
|
db362b0126 | ||
|
|
ab8096071c | ||
|
|
3c9883bb73 | ||
|
|
dd5a3db55e | ||
|
|
f165efcd0b | ||
|
|
8842414d8a | ||
|
|
fa367dd99e | ||
|
|
c9b5b1a892 | ||
|
|
7509a32e9d | ||
|
|
ca1e17f740 | ||
|
|
d59cd14391 | ||
|
|
4d21044ba5 | ||
|
|
81a37b67ed |
254
.env.example
254
.env.example
@@ -56,10 +56,12 @@ STORAGE_ENCRYPTION_KEY_VERSION=v1
|
||||
DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
|
||||
# ── Redis (Rate Limiting) ──
|
||||
# Redis connection URL for the rate limiter backend.
|
||||
# Redis connection URL for the rate limiter backend. OPT-IN: leave this
|
||||
# commented out to use the built-in in-memory rate limiter. Setting it to a
|
||||
# non-running localhost (#4878) makes ioredis flood "[REDIS] Error:" logs.
|
||||
# Used by: src/shared/utils/rateLimiter.ts
|
||||
# Default: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# Example: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 3. NETWORK & PORTS
|
||||
@@ -109,9 +111,13 @@ PORT=20128
|
||||
# Used by: src/app/api/v1/relay/chat/completions/route.ts
|
||||
# RELAY_IP_PER_MINUTE=30
|
||||
|
||||
# Use Turbopack in local dev. Next 16.2.4 can fail to compile next/font/google
|
||||
# through the custom dev runner without this on Windows.
|
||||
OMNIROUTE_USE_TURBOPACK=1
|
||||
# Bundler selection for `npm run dev`. Set to 1 to opt into Turbopack.
|
||||
# Default is 0 (webpack) because Turbopack 16.2.x panics on the OmniRoute
|
||||
# module graph with "internal error: entered unreachable code: there must be
|
||||
# a path to a root" (turbopack-core/module_graph/mod.rs:662). Same bug class
|
||||
# the production Docker build worked around in PR #4052. Webpack starts
|
||||
# slower but compiles cleanly. Re-enable once upstream Turbopack ships a fix.
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
|
||||
# Skip the SQLite integrity health check on startup (faster boot on large DBs).
|
||||
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip.
|
||||
@@ -189,7 +195,8 @@ AUTH_COOKIE_SECURE=false
|
||||
REQUIRE_API_KEY=false
|
||||
|
||||
# Allow revealing full API key values in the Dashboard UI.
|
||||
# Used by: Dashboard providers page — controls show/hide of key values.
|
||||
# Used by: src/shared/constants/featureFlagDefinitions.ts — controls show/hide of key values.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
# Default: false | Security risk if enabled on shared instances.
|
||||
ALLOW_API_KEY_REVEAL=false
|
||||
|
||||
@@ -273,6 +280,15 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# PII_RESPONSE_SANITIZATION=false
|
||||
# PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response
|
||||
|
||||
# ── VS Code Tokenized-Route Context Sanitizer ──
|
||||
# Strips implicit active-editor context (editorContext/activeEditor/currentFile/
|
||||
# selection/openTabs…) from requests on the /v1/vscode/[token]/* routes before
|
||||
# forwarding upstream, and redacts the content of explicitly-attached sensitive
|
||||
# files (.env, private keys, kubeconfig, credentials/secrets). Explicit
|
||||
# attachments otherwise pass through. Secure-by-default: ON unless set to 0.
|
||||
# Used by: src/app/api/v1/vscode/contextSanitizer.ts
|
||||
# OMNIROUTE_VSCODE_SANITIZE_CONTEXT=1 # set to 0 to disable
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 6. TOOL & ROUTING POLICIES
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -370,6 +386,22 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
|
||||
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
|
||||
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
|
||||
#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace
|
||||
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings
|
||||
|
||||
# OpenCode Go dashboard quota scraping. Prefer configuring these per connection
|
||||
# in Dashboard → Providers → OpenCode Go. Env vars are useful for headless
|
||||
# deployments or shared server defaults. The cookie is sensitive.
|
||||
#OPENCODE_GO_WORKSPACE_ID=wrk_...
|
||||
#OMNIROUTE_OPENCODE_GO_WORKSPACE_ID=wrk_...
|
||||
#OPENCODE_GO_AUTH_COOKIE=auth=...
|
||||
#OMNIROUTE_OPENCODE_GO_AUTH_COOKIE=auth=...
|
||||
|
||||
# Ollama Cloud quota scraping. Prefer configuring this per connection in
|
||||
# Dashboard → Providers → Ollama Cloud. The cookie is sensitive.
|
||||
#OLLAMA_USAGE_COOKIE=__Secure-session=...
|
||||
#OLLAMA_CLOUD_USAGE_COOKIE=__Secure-session=...
|
||||
#OMNIROUTE_OLLAMA_USAGE_COOKIE=__Secure-session=...
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 8. OUTBOUND PROXY (Upstream Provider Calls)
|
||||
@@ -388,6 +420,12 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# ALL_PROXY=socks5://127.0.0.1:7890
|
||||
# NO_PROXY=localhost,127.0.0.1
|
||||
|
||||
# Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher.
|
||||
# Long-lived SSE streams such as Codex /v1/responses need more than one
|
||||
# connection when multiple requests share the same account-level proxy.
|
||||
# Set to 1 only for legacy diagnostics. Values above 256 are capped.
|
||||
# OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS=32
|
||||
|
||||
# Proxy fail-open mode (default: false = fail-closed).
|
||||
# When false, a request whose assigned proxy fails to resolve is REFUSED rather than
|
||||
# falling back to a direct connection — prevents real-IP leaks in egress-controlled
|
||||
@@ -460,6 +498,11 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Legacy alias for OMNIROUTE_API_KEY.
|
||||
# ROUTER_API_KEY=
|
||||
|
||||
# CLI remote-mode context/profile for `omniroute` commands (overrides the active
|
||||
# context in the local contexts store). Equivalent to the `--context <name>` flag.
|
||||
# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode).
|
||||
# OMNIROUTE_CONTEXT=
|
||||
|
||||
# Enforce scope-based access control on MCP tool calls.
|
||||
# Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes.
|
||||
# OMNIROUTE_MCP_ENFORCE_SCOPES=false
|
||||
@@ -510,6 +553,16 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# heuristic in instrumentation-node.ts. Default: unset (tests skip background).
|
||||
#OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS=1
|
||||
|
||||
# Proactive connection-cooldown recovery (#8): re-validates connections whose
|
||||
# transient `rate_limited_until` window has elapsed OUTSIDE the request hot path,
|
||||
# so the first request after a cooldown does not pay the probe latency. Lazy
|
||||
# recovery in getProviderCredentials still applies regardless. Used by:
|
||||
# src/lib/quota/connectionRecovery.ts.
|
||||
# Tick cadence (ms). Default 60000, floor 5000.
|
||||
# OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS=60000
|
||||
# Disable the proactive recovery scheduler entirely (default: false).
|
||||
# OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false
|
||||
|
||||
# Background job interval for budget reset checks (ms). Default: 600000 (10m).
|
||||
# Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000.
|
||||
#OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000
|
||||
@@ -557,6 +610,11 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: scripts/postinstall.mjs.
|
||||
#OMNIROUTE_SKIP_POSTINSTALL=0
|
||||
|
||||
# Operator-supplied JSON credentials for the offline compression-eval CLI
|
||||
# (parsed with JSON.parse; leave unset for a dry run). Developer tooling only.
|
||||
# Used by: scripts/compression-eval/index.ts. Default: {} (empty).
|
||||
#OMNIROUTE_EVAL_CREDENTIALS={}
|
||||
|
||||
# Skip the DB healthcheck entirely on startup (useful for short-lived tasks / tests).
|
||||
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to disable. Default: 0.
|
||||
#OMNIROUTE_SKIP_DB_HEALTHCHECK=0
|
||||
@@ -751,7 +809,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
|
||||
# Update these when providers release new CLI versions to avoid blocks.
|
||||
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)"
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.187 (external, cli)"
|
||||
|
||||
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
|
||||
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
|
||||
@@ -760,15 +818,21 @@ CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)"
|
||||
# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to
|
||||
# forward the original names verbatim (debugging only).
|
||||
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
|
||||
CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)"
|
||||
CODEX_USER_AGENT="codex-cli/0.142.0 (Windows 10.0.26200; x64)"
|
||||
GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1"
|
||||
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
|
||||
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
|
||||
# KIRO_VERIFY_FULL_CRC=false # opt-in: full per-frame message CRC validation on the Kiro event stream (debug corrupted streams; prelude CRC + TLS already protect framing)
|
||||
# Optional override for the Kiro social device-code OAuth clientId. Kiro's
|
||||
# device endpoint accepts any non-empty string and behaves like a User-Agent
|
||||
# rather than a secret. Only override if AWS ever starts enforcing this field.
|
||||
# Used by: src/lib/oauth/constants/oauth.ts (KIRO_CONFIG.socialClientId).
|
||||
# KIRO_OAUTH_CLIENT_ID=kiro-cli
|
||||
# Enable full per-frame message CRC validation for Kiro streams. Off by default
|
||||
# because it is O(frame bytes) on the main thread; use only for debugging
|
||||
# suspected corrupted-stream issues.
|
||||
# Used by: open-sse/executors/kiro.ts
|
||||
# KIRO_VERIFY_FULL_CRC=false
|
||||
QODER_USER_AGENT="Qoder-Cli"
|
||||
QWEN_USER_AGENT="QwenCode/0.15.11 (linux; x64)"
|
||||
CURSOR_USER_AGENT="Cursor/3.4"
|
||||
@@ -776,7 +840,13 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
|
||||
# Override Codex client version sent in headers independently of the
|
||||
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
|
||||
# CODEX_CLIENT_VERSION=0.132.0
|
||||
# CODEX_CLIENT_VERSION=0.142.0
|
||||
|
||||
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
|
||||
# from the Codex Responses stream. These frames break the OpenAI SDK's
|
||||
# responses.stream() with a 502 "Controller is already closed". Off by default;
|
||||
# set to true/1/yes to enable. Used by: open-sse/executors/codex.ts.
|
||||
# OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS=true
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection)
|
||||
@@ -903,6 +973,15 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD=2
|
||||
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS=15000
|
||||
|
||||
# ── Context-cache pin health gate ──
|
||||
# Used by: open-sse/services/combo.ts. When a context-cache pin points at a
|
||||
# provider that is durably unhealthy, the pin is dropped to allow failover.
|
||||
# PIN_DROP_BACKOFF_LEVEL gates how deep a connection's backoff must be before the
|
||||
# pin is considered durably unhealthy; PIN_DROP_GRACE_MS is the anti-flap window
|
||||
# that tolerates brief transient cooldowns before dropping the pin.
|
||||
# PIN_DROP_BACKOFF_LEVEL=2
|
||||
# PIN_DROP_GRACE_MS=20000
|
||||
|
||||
# ── Stream idle detection ──
|
||||
# STREAM_IDLE_TIMEOUT_MS=600000 # Max silence between SSE chunks (default: 600000)
|
||||
# # Extended-thinking models rarely pause >90s.
|
||||
@@ -975,6 +1054,11 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 100000
|
||||
# CALL_LOGS_TABLE_MAX_ROWS=100000
|
||||
|
||||
# Maximum age for orphaned active request log entries before the in-memory
|
||||
# pending-request reaper removes them. Accepts milliseconds.
|
||||
# Default: 3600000 (1 hour)
|
||||
# MAX_PENDING_REQUEST_AGE_MS=3600000
|
||||
|
||||
# Whether call log pipeline capture stores stream chunks when enabled in settings.
|
||||
# Only applies when call_log_pipeline_enabled=true.
|
||||
# Default: true
|
||||
@@ -1148,6 +1232,34 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/executors/cloudflare-ai.ts
|
||||
# CLOUDFLARE_ACCOUNT_ID=
|
||||
|
||||
# ── Deno Deploy proxy relay (#4643 / 9router#1437) ──
|
||||
# Override the Deno Deploy REST API base used by the proxy-pool relay deployer.
|
||||
# Default: https://api.deno.com/v2 (omit unless mocking).
|
||||
# Used by: src/app/api/settings/proxy/deno-deploy/route.ts
|
||||
# DENO_DEPLOY_API_BASE=https://api.deno.com/v2
|
||||
|
||||
# Default Deno Deploy app name suggested in the "Deploy Relay" modal.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx
|
||||
# NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT=omniroute-deno-relay
|
||||
|
||||
# Set to "false" to hide the Deno Deploy relay option from the Proxy Pool tab.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx
|
||||
# NEXT_PUBLIC_DENO_RELAY_ENABLED=true
|
||||
|
||||
# ── Cloudflare Workers proxy relay (#4640 / 9router#1360) ──
|
||||
# Override the Cloudflare REST API base used by the proxy-pool relay deployer.
|
||||
# Default: https://api.cloudflare.com/client/v4 (omit unless mocking).
|
||||
# Used by: src/app/api/settings/proxy/cloudflare-deploy/route.ts
|
||||
# CLOUDFLARE_API_BASE=https://api.cloudflare.com/client/v4
|
||||
|
||||
# Default worker project name suggested in the "Deploy Relay" modal.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx
|
||||
# NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT=omniroute-relay
|
||||
|
||||
# Set to "false" to hide the Cloudflare Workers relay option from the Proxy Pool tab.
|
||||
# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx
|
||||
# NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED=true
|
||||
|
||||
# ── Cloudflare Tunnel (cloudflared) ──
|
||||
# Custom path to cloudflared binary for tunnel management.
|
||||
# Used by: src/lib/cloudflaredTunnel.ts
|
||||
@@ -1212,6 +1324,11 @@ APP_LOG_TO_FILE=true
|
||||
# Health check result cache TTL (ms). Default: 30000 (30s)
|
||||
# PROXY_HEALTH_CACHE_TTL_MS=30000
|
||||
|
||||
# Allow OAuth and provider validation flows to bypass a pinned proxy and connect
|
||||
# directly when proxy reachability pre-checks fail. Default: false.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
# OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK=false
|
||||
|
||||
# Rate limit maximum wait time before failing a request (ms). Default: 120000 (2 min)
|
||||
# Used by: open-sse/services/rateLimitManager.ts
|
||||
# RATE_LIMIT_MAX_WAIT_MS=120000
|
||||
@@ -1240,6 +1357,25 @@ APP_LOG_TO_FILE=true
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# PROVIDER_COOLDOWN_ENABLED=true
|
||||
|
||||
# Transparent stream recovery (free-claude-code port). When enabled, the opening SSE
|
||||
# window is briefly held (up to STREAM_RECOVERY.HOLDBACK_MS) so an upstream truncation
|
||||
# before any byte reaches the client can be retried invisibly. Opt-in: holding the
|
||||
# window adds up to that much time-to-first-token latency on every stream, so it is
|
||||
# OFF by default. Seeds ResilienceSettings.streamRecovery.enabled.
|
||||
# Used by: open-sse/services/streamRecovery.ts, open-sse/handlers/chatCore.ts
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# STREAM_RECOVERY_ENABLED=true
|
||||
|
||||
# Mid-stream continuation (Fase 4.4): when an upstream stream truncates AFTER bytes
|
||||
# already reached the client, re-request with the partial text as an assistant prefill
|
||||
# and stitch the missing suffix (plain-text OpenAI-compatible streams only; never with a
|
||||
# tool call in flight). OFF by default — the recovered tail arrives as one burst, not
|
||||
# token-by-token. Independent of STREAM_RECOVERY_ENABLED (different risk profile).
|
||||
# Seeds ResilienceSettings.streamRecovery.continueMidStream.
|
||||
# Used by: open-sse/services/streamRecovery.ts, open-sse/handlers/chatCore.ts
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# STREAM_RECOVERY_MIDSTREAM_ENABLED=true
|
||||
|
||||
# Stagger interval (ms) between provider token healthchecks at startup.
|
||||
# Used by: src/lib/tokenHealthCheck.ts. Default: 3000.
|
||||
# HEALTHCHECK_STAGGER_MS=3000
|
||||
@@ -1363,6 +1499,12 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: src/mitm/server.cjs — captures upstream traffic for inspection.
|
||||
# MITM_LOCAL_PORT=443
|
||||
# MITM_DISABLE_TLS_VERIFY=0
|
||||
# Idle socket timeout (ms) for proxied connections; sockets idle past this are torn
|
||||
# down to avoid leaking half-open tunnels (src/mitm/socketTimeouts.ts, server.cjs).
|
||||
# MITM_IDLE_TIMEOUT_MS=60000
|
||||
# Routing-decision log verbosity: 0 silences, higher values log more bypass/route
|
||||
# decisions (src/mitm/server.cjs, _internal/bypass.cjs).
|
||||
# MITM_VERBOSE=1
|
||||
|
||||
# ── 1Proxy egress pool ──
|
||||
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
|
||||
@@ -1406,6 +1548,10 @@ APP_LOG_TO_FILE=true
|
||||
# dashboard's tunnel manager. Used by: src/lib/tailscaleTunnel.ts.
|
||||
# TAILSCALE_BIN=/usr/local/bin/tailscale
|
||||
# TAILSCALED_BIN=/usr/local/bin/tailscaled
|
||||
# Pre-shared Tailscale auth key for non-interactive / headless `tailscale up`
|
||||
# (passed via --auth-key=). When unset, login falls back to the interactive
|
||||
# browser auth URL. Used by: src/lib/tailscaleTunnel.ts.
|
||||
# TAILSCALE_AUTHKEY=
|
||||
|
||||
# ── Ngrok tunnel ──
|
||||
# Used by: src/lib/ngrokTunnel.ts — authenticates outbound tunnels.
|
||||
@@ -1551,7 +1697,17 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
|
||||
# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
|
||||
# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
|
||||
# STATUS_SOFT_DEPRIORITIZE_FACTOR=0.5 # 0..1; multiplicador do score p/ provider esgotado (credits_exhausted/rate_limited) quando preflight cutoff OFF (#4540)
|
||||
# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos
|
||||
# QUOTA_PREFLIGHT_CUTOFF_ENABLED=false # opt-in (default OFF): hard quota cutoff drops low-quota candidates before auto-routing scoring
|
||||
|
||||
# ─── Auto-Combo tier filter (#4517) ───────────────────────────────────────
|
||||
# When an `auto/<category>:free` (or any `:<tier>`) request matches NO connected
|
||||
# candidates, OmniRoute returns an EMPTY pool by default — so `:free` really means
|
||||
# "free tier only" and a paid model is never picked just because no free provider is
|
||||
# connected. Set this to `true`/`1` to restore the legacy behavior of falling back to
|
||||
# the full (unfiltered) pool with a warning. Source: open-sse/services/autoCombo/virtualFactory.ts
|
||||
# OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=false
|
||||
|
||||
# ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ───
|
||||
# Base URL of the OmniRoute instance to query for /v1/models when regenerating
|
||||
@@ -1564,3 +1720,81 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by:
|
||||
# scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY.
|
||||
# OPENCODE_API_KEY=
|
||||
|
||||
# ─── Bifrost Go sidecar (PR-4 in #3932) ──────────────────────────────────────
|
||||
# Master kill switch for the bifrost sidecar proxy. When set to 0, the
|
||||
# /api/v1/relay/chat/completions/bifrost route returns 503 with the
|
||||
# X-Bifrost-Killswitch header and the operator is bounced to the TS path.
|
||||
# Use this to disable the sidecar without redeploying (e.g. during a
|
||||
# tier-1 router incident or a key rotation). Default: 1 (sidecar active).
|
||||
# BIFROST_ENABLED=1
|
||||
# When BIFROST_BASE_URL is set, /api/v1/relay/chat/completions/bifrost routes
|
||||
# traffic to the Go gateway instead of the TS relay handler, removing TS from
|
||||
# the hot path. Auth/rate-limit/injection-guard stay in the route (security not
|
||||
# duplicated). Falls back to TS path via X-Bifrost-Fallback header on
|
||||
# timeout/failure. See bin/omniroute for the local-redis companion.
|
||||
# BIFROST_BASE_URL=
|
||||
# API key for the Bifrost gateway (sent as Authorization: Bearer ...). If
|
||||
# unset, the route expects the request to carry a valid OmniRoute API key;
|
||||
# this key is for gateway-side auth only.
|
||||
# BIFROST_API_KEY=
|
||||
# When true, the Bifrost sidecar route streams responses back via SSE through
|
||||
# the gateway rather than the TS streaming executor. Default: true (when
|
||||
# BIFROST_BASE_URL is set).
|
||||
# BIFROST_STREAMING_ENABLED=
|
||||
# Per-request timeout when proxying to the Bifrost gateway. Default: 30000 (30s).
|
||||
# BIFROST_TIMEOUT_MS=
|
||||
# Alias for BIFROST_API_KEY (used by scripts that read the env via
|
||||
# OMNIROUTE_*). Falls back to BIFROST_API_KEY when unset.
|
||||
# OMNIROUTE_BIFROST_KEY=
|
||||
|
||||
# ─── 1-click local service launchers (PR-3 in #3932) ────────────────────────
|
||||
# Master switch for /api/local/* routes. When unset or "0", all /api/local/*
|
||||
# routes return 503 in production. Default: 0. Must be "1" in non-loopback
|
||||
# deploys to enable the Redis launcher and similar 1-click local service
|
||||
# starters. Belt-and-suspenders with the isLocalOnlyPath() route-guard
|
||||
# classification (LOCAL_ONLY_API_PREFIXES in src/server/authz/routeGuard.ts).
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=
|
||||
# Bearer token for /api/local/* callers that aren't on loopback (e.g. the
|
||||
# desktop app). When set, requests from non-loopback IPs must carry
|
||||
# Authorization: Bearer <token>. Required when
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1 in non-loopback deployments. Default:
|
||||
# unset (loopback-only).
|
||||
# OMNIROUTE_LOCAL_ENDPOINTS_TOKEN=
|
||||
# Container name for the 1-click Redis launcher (`omniroute redis up`).
|
||||
# Default: omniroute-redis. Used by bin/cli/commands/redis.mjs and the
|
||||
# RedisLauncherPanel.
|
||||
# OMNIROUTE_REDIS_CONTAINER_NAME=
|
||||
# Host port for the 1-click Redis launcher. Default: 6379. Bump if the host
|
||||
# already binds 6379. The container's internal port stays 6379.
|
||||
# OMNIROUTE_REDIS_HOST_PORT=
|
||||
# Redis image used by the 1-click Redis launcher. Default: redis:7-alpine.
|
||||
# Override to redis:8-alpine or a private registry mirror as needed.
|
||||
# OMNIROUTE_REDIS_IMAGE=
|
||||
|
||||
# ── Cluster Profile: Qdrant Vector Memory (opt-in via `docker compose --profile memory up`) ──
|
||||
# Qdrant is an OPTIONAL sidecar for deployments that need cosine-distance vector
|
||||
# search at >1M embeddings. The default vector store is sqlite-vec
|
||||
# (src/lib/memory/vectorStore.ts:108); flip this profile on only if you hit the
|
||||
# sqlite-vec ceiling or want persistent cross-replica vector state. See
|
||||
# docs/architecture/cluster-decisions.md § "Qdrant (memory profile)".
|
||||
# QDRANT_HOST=qdrant
|
||||
# QDRANT_PORT=6333
|
||||
# QDRANT_GRPC_PORT=6334
|
||||
# QDRANT_API_KEY=
|
||||
# QDRANT_COLLECTION=omniroute-memory
|
||||
# QDRANT_EMBEDDING_MODEL=text-embedding-3-small
|
||||
# QDRANT_VECTOR_SIZE=1536
|
||||
# QDRANT_HNSW_EF_CONSTRUCT=128
|
||||
|
||||
# ── Cluster Profile: Bifrost Tier-1 Router (opt-in via `docker compose --profile bifrost up`) ──
|
||||
# Bifrost is an OPTIONAL Go-based Tier-1 router that handles the upstream-provider
|
||||
# multiplexing layer. Default: OmniRoute's open-sse/executors/bifrost.ts in-process
|
||||
# executor handles routing directly. Flip this profile on only if you want the
|
||||
# gateway as a separate sidecar (helps in 3+ replica deployments where you want
|
||||
# provider rotation centralised). See docs/architecture/cluster-decisions.md §
|
||||
# "Bifrost (bifrost profile)".
|
||||
# BIFROST_BASE_URL=http://bifrost:8080
|
||||
# BIFROST_API_KEY=
|
||||
# BIFROST_STREAMING_ENABLED=true
|
||||
# BIFROST_TIMEOUT_MS=30000
|
||||
|
||||
29
.github/actions/npm-ci-retry/action.yml
vendored
Normal file
29
.github/actions/npm-ci-retry/action.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: npm ci with retry
|
||||
description: Run npm ci with retries for transient registry/network failures.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
max_attempts=3
|
||||
delay_seconds=20
|
||||
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
if [ "$attempt" -gt 1 ]; then
|
||||
echo "npm ci attempt $attempt/$max_attempts after transient failure"
|
||||
fi
|
||||
|
||||
if npm ci; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit_code=$?
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
exit "$exit_code"
|
||||
fi
|
||||
|
||||
sleep "$delay_seconds"
|
||||
delay_seconds=$((delay_seconds * 2))
|
||||
done
|
||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -24,6 +24,20 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "eslint-config-next"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API).
|
||||
# scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it
|
||||
# parses jscpd-report.json against a frozen baseline). A v5 major would break
|
||||
# the duplication gate — migrate the gate intentionally, not via dependabot.
|
||||
- dependency-name: "jscpd"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
|
||||
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
|
||||
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
|
||||
# and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts),
|
||||
# and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must
|
||||
# be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore
|
||||
# every version). Migrate it intentionally, not via dependabot (#4050).
|
||||
- dependency-name: "@huggingface/transformers"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
4
.github/workflows/build-fork.yml
vendored
4
.github/workflows/build-fork.yml
vendored
@@ -25,7 +25,9 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
377
.github/workflows/ci.yml
vendored
377
.github/workflows/ci.yml
vendored
@@ -21,6 +21,74 @@ env:
|
||||
CI_NODE_26_VERSION: "26"
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Change Classification
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code: ${{ steps.classify.outputs.code }}
|
||||
docs: ${{ steps.classify.outputs.docs }}
|
||||
i18n: ${{ steps.classify.outputs.i18n }}
|
||||
workflow: ${{ steps.classify.outputs.workflow }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||
{
|
||||
echo "code=true"
|
||||
echo "docs=true"
|
||||
echo "i18n=true"
|
||||
echo "workflow=true"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
code=false
|
||||
docs=false
|
||||
i18n=false
|
||||
workflow=false
|
||||
|
||||
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
|
||||
|
||||
while IFS= read -r file; do
|
||||
case "$file" in
|
||||
.github/workflows/*|.zizmor.yml)
|
||||
workflow=true
|
||||
code=true
|
||||
;;
|
||||
docs/*|*.md)
|
||||
docs=true
|
||||
;;
|
||||
src/i18n/*|src/i18n/messages/*|scripts/i18n/*|config/i18n.json)
|
||||
i18n=true
|
||||
code=true
|
||||
;;
|
||||
src/*|open-sse/*|bin/*|electron/*|tests/*|scripts/*|package.json|package-lock.json|tsconfig*.json|next.config.*|vitest*.config.*|playwright.config.*)
|
||||
code=true
|
||||
;;
|
||||
db/*|config/*)
|
||||
code=true
|
||||
;;
|
||||
*)
|
||||
code=true
|
||||
;;
|
||||
esac
|
||||
done < changed-files.txt
|
||||
|
||||
{
|
||||
echo "code=$code"
|
||||
echo "docs=$docs"
|
||||
echo "i18n=$i18n"
|
||||
echo "workflow=$workflow"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,12 +99,14 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run audit:deps
|
||||
- run: npm run lint
|
||||
@@ -57,7 +127,8 @@ jobs:
|
||||
- run: npm run check:tracked-artifacts
|
||||
- run: npm run check:lockfile
|
||||
- run: npm run check:licenses
|
||||
- run: npm run check:docs-sync
|
||||
# check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the
|
||||
# husky pre-commit hook; the standalone copy here was redundant (ROI dedup).
|
||||
- run: npm run typecheck:core
|
||||
# typecheck:noimplicit:core is a forward-looking gate (noImplicitAny).
|
||||
# Run informationally for now — many pre-existing call sites still need
|
||||
@@ -69,19 +140,21 @@ jobs:
|
||||
name: Quality Ratchet
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-coverage
|
||||
if: ${{ always() && needs.test-coverage.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.test-coverage.result == 'success' }}
|
||||
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
|
||||
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: read
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -93,14 +166,14 @@ jobs:
|
||||
# coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados.
|
||||
- name: Ratchet check
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
|
||||
# Fase 6A.5: require-tighten — ADVISORY por enquanto (continue-on-error). Reporta
|
||||
# quando uma métrica MELHOROU sem o baseline ter sido apertado no mesmo PR (força
|
||||
# capturar ganhos permanentes). As métricas coverage.* carregam tightenSlack para
|
||||
# o gap anti-flake (CI mergeado > baseline) não disparar falso-positivo. Promover a
|
||||
# BLOQUEANTE no fim do ciclo removendo o `continue-on-error` (ver a nota
|
||||
# _require_tighten_advisory em config/quality/quality-baseline.json).
|
||||
- name: Require-tighten (advisory — flip to blocking at cycle-end)
|
||||
continue-on-error: true
|
||||
# Fase 6A.5: require-tighten — BLOQUEANTE (promovido de advisory no fim do ciclo
|
||||
# v3.8.27). Falha quando uma métrica MELHOROU sem o baseline ter sido apertado no
|
||||
# mesmo PR (força capturar ganhos permanentes). As métricas coverage.* carregam
|
||||
# tightenSlack para o gap anti-flake (CI mergeado > baseline) não disparar falso-
|
||||
# positivo. Verificado limpo (exit 0) no tip de release/v3.8.27 com a cobertura
|
||||
# mergeada == baseline (ver a nota _require_tighten_flip_blocking em
|
||||
# config/quality/quality-baseline.json).
|
||||
- name: Require-tighten (blocking)
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --require-tighten
|
||||
# Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo)
|
||||
# para não pesar no caminho crítico do lint.
|
||||
@@ -117,6 +190,8 @@ jobs:
|
||||
run: npm run check:cognitive-complexity
|
||||
- name: Type coverage ratchet
|
||||
run: npm run check:type-coverage
|
||||
- name: Compression budget ratchet (F2.4 / N4)
|
||||
run: npm run check:compression-budget
|
||||
# CodeQL alerts ratchet — BLOQUEANTE (promovido de advisory na v3.8.26).
|
||||
# Lê metrics.codeqlAlerts.value de quality-baseline.json e sai 1 SOMENTE numa
|
||||
# regressão real (alertas abertos > baseline). Falha de medição (gh/auth/api)
|
||||
@@ -152,16 +227,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
||||
# spec via `git show <base_ref>:docs/reference/openapi.yaml`; a shallow clone
|
||||
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
|
||||
# would lack the base ref and the gate would self-skip (base-unresolved).
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# Dead-code, cognitive-complexity, type-coverage foram promovidos ao job
|
||||
# quality-gate (bloqueante) na Fase 7 INT — não rodam aqui para evitar duplo custo.
|
||||
- name: Circular deps (dpdm; advisory)
|
||||
@@ -227,9 +303,14 @@ jobs:
|
||||
# is absent (a missing binary never blocks).
|
||||
- name: Secret scan (gitleaks, ratchet, blocking)
|
||||
run: npm run check:secrets -- --ratchet
|
||||
- name: Vulnerability ratchet (osv-scanner; advisory, skips if absent)
|
||||
continue-on-error: true
|
||||
run: npm run check:vuln-ratchet
|
||||
# BLOCKING ratchet (v3.8.27 cycle-end): vulnCount must not regress vs the
|
||||
# baseline. --ratchet exits 1 on a measured regression (measured > baseline);
|
||||
# it SKIPs (exit 0) when osv-scanner is absent or osv.dev is unreachable (a
|
||||
# missing/failed measurement never blocks). See the CVE-variance note in
|
||||
# docs/security/SUPPLY_CHAIN.md — a newly-disclosed CVE on an unchanged dep can
|
||||
# red this gate; the fix is to bump the dep or re-baseline metrics.vulnCount.
|
||||
- name: Vulnerability ratchet (osv-scanner, ratchet, blocking)
|
||||
run: npm run check:vuln-ratchet -- --ratchet
|
||||
# BLOCKING ratchet (Etapa 2): zizmorFindings must not regress vs the baseline.
|
||||
# ONLY zizmor is ratcheted — actionlint findings are reported, not blocking.
|
||||
# --ratchet exits 1 on a measured zizmor regression; it SKIPs (exit 0) when
|
||||
@@ -237,26 +318,29 @@ jobs:
|
||||
- name: Workflow lint (actionlint+zizmor, ratchet, blocking)
|
||||
run: npm run check:workflows -- --ratchet
|
||||
# OpenAPI breaking-change detection (oasdiff). Diffs the PR's public API
|
||||
# contract (docs/reference/openapi.yaml) against the base branch's spec.
|
||||
# ADVISORY: reports `openapiBreaking=N` and self-skips when oasdiff is absent
|
||||
# or the base spec can't be resolved. BASE_REF is read by the script from the
|
||||
# env (never interpolated into a shell body) — workflow-injection-safe.
|
||||
- name: OpenAPI breaking-change (oasdiff; advisory)
|
||||
continue-on-error: true
|
||||
# contract (docs/openapi.yaml) against the base branch's spec.
|
||||
# BLOCKING ratchet (Fase 9 Onda 0): reads metrics.openapiBreaking.value and
|
||||
# exits 1 ONLY on a measured regression (count > baseline). It SKIPs (exit 0)
|
||||
# when oasdiff is absent or the base spec can't be resolved — a missing
|
||||
# measurement never blocks. BASE_REF is read by the script from the env
|
||||
# (never interpolated into a shell body) — workflow-injection-safe.
|
||||
- name: OpenAPI breaking-change (oasdiff, ratchet, blocking)
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: npm run check:openapi-breaking
|
||||
run: npm run check:openapi-breaking -- --ratchet
|
||||
|
||||
docs-sync-strict:
|
||||
name: Docs Sync (Strict)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:docs-all
|
||||
# Previously-orphaned contract gates (existed as files, never wired anywhere).
|
||||
# All exit 0 today: cli-i18n is a hard gate, openapi-coverage is a ratchet
|
||||
@@ -281,7 +365,9 @@ jobs:
|
||||
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
@@ -302,12 +388,14 @@ jobs:
|
||||
name: i18n UI Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
||||
|
||||
i18n-matrix:
|
||||
@@ -316,7 +404,9 @@ jobs:
|
||||
outputs:
|
||||
langs: ${{ steps.langs.outputs.langs }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- id: langs
|
||||
run: |
|
||||
LANG_DIR="src/i18n/messages"
|
||||
@@ -333,7 +423,9 @@ jobs:
|
||||
lang: ${{ fromJson(needs.i18n-matrix.outputs.langs) }}
|
||||
needs: i18n-matrix
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
@@ -360,8 +452,9 @@ jobs:
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -388,28 +481,40 @@ jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Next.js build cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae
|
||||
with:
|
||||
path: .build/next/cache
|
||||
key: nextjs-${{ runner.os }}-node-${{ env.CI_NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*', 'open-sse/**/*', 'db/**/*', 'next.config.mjs', 'tsconfig*.json', 'postcss.config.*', 'tailwind.config.*') }}
|
||||
restore-keys: |
|
||||
nextjs-${{ runner.os }}-node-${{ env.CI_NODE_VERSION }}-${{ hashFiles('package-lock.json') }}-
|
||||
- run: npm run build
|
||||
- name: Archive Next.js build for E2E shards
|
||||
- name: Archive Next.js build for downstream jobs
|
||||
# Use tar so the archive preserves paths relative to CWD (.build/next/...).
|
||||
# upload-artifact path-stripping is ambiguous when exclude patterns are used;
|
||||
# an explicit tar avoids the double-nesting issue (.build/next/next/...).
|
||||
# Keep standalone/node_modules intact: package/electron jobs consume the
|
||||
# Next-traced standalone tree and must not replace it with root node_modules.
|
||||
run: |
|
||||
tar -czf /tmp/e2e-build.tar.gz \
|
||||
--exclude='.build/next/standalone/node_modules' \
|
||||
--exclude='.build/next/cache' \
|
||||
.build/next
|
||||
- name: Upload Next.js build for E2E shards
|
||||
- name: Upload Next.js build for downstream jobs
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-next-build
|
||||
name: next-build
|
||||
path: /tmp/e2e-build.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
@@ -420,15 +525,25 @@ jobs:
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
# build:cli runs a clean build into .build/next and assembles dist/
|
||||
# For release builds prefer: npm run build:release (clean rebuild + HEAD sentinel)
|
||||
- name: Download Next.js build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
# build:cli consumes the downloaded .build/next standalone artifact and assembles dist/;
|
||||
# it only rebuilds if the downloaded standalone artifact is missing.
|
||||
- run: npm run build:cli
|
||||
- name: Assert dist/server.js exists
|
||||
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
|
||||
@@ -443,14 +558,23 @@ jobs:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
- name: Download Next.js build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: next-build
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
- name: Install Electron dependencies
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
@@ -476,14 +600,16 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --max-old-space-size=4096 --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
test-vitest:
|
||||
name: Vitest (MCP / autoCombo / UI components)
|
||||
@@ -495,12 +621,14 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
|
||||
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
|
||||
- run: npm run test:vitest
|
||||
@@ -511,52 +639,82 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
node-24-compat:
|
||||
name: Node 24 Compatibility (${{ matrix.shard }}/2)
|
||||
name: Node 24 Compatibility Tests (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_24_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
- run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
node-26-compat:
|
||||
name: Node 26 Compatibility (${{ matrix.shard }}/2)
|
||||
node-26-compat-build:
|
||||
name: Node 26 Compatibility Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 25
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_26_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Next.js build cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae
|
||||
with:
|
||||
path: .build/next/cache
|
||||
key: nextjs-${{ runner.os }}-node-${{ env.CI_NODE_26_VERSION }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**/*', 'open-sse/**/*', 'db/**/*', 'next.config.mjs', 'tsconfig*.json', 'postcss.config.*', 'tailwind.config.*') }}
|
||||
restore-keys: |
|
||||
nextjs-${{ runner.os }}-node-${{ env.CI_NODE_26_VERSION }}-${{ hashFiles('package-lock.json') }}-
|
||||
- run: npm run build
|
||||
- run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
node-26-compat:
|
||||
name: Node 26 Compatibility Tests (${{ matrix.shard }}/4)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: node-26-compat-build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_26_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
test-coverage-shard:
|
||||
name: Coverage Shard (${{ matrix.shard }}/8)
|
||||
@@ -572,12 +730,14 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Run c8 over shard ${{ matrix.shard }}/8
|
||||
run: |
|
||||
@@ -594,8 +754,8 @@ jobs:
|
||||
--reporter=json \
|
||||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
node --max-old-space-size=4096 --import tsx --test --test-force-exit --test-concurrency=4 \
|
||||
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \
|
||||
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- name: Upload raw shard coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -609,17 +769,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: test-coverage-shard
|
||||
if: ${{ always() && needs.test-coverage-shard.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.test-coverage-shard.result == 'success' }}
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- name: Download all shard coverage
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -627,14 +789,18 @@ jobs:
|
||||
path: coverage-shards/
|
||||
merge-multiple: true
|
||||
- name: Merge + report + gate
|
||||
# Merging 8 shards of raw v8 coverage is memory-heavy; the 6 GB heap can
|
||||
# still OOM on large PR runs. Keep this job focused on the gate and
|
||||
# JSON summary that downstream ratchets consume.
|
||||
# Merging 8 shards of raw v8 coverage is memory-heavy. `--merge-async`
|
||||
# keeps the V8 coverage merge incremental instead of loading every raw
|
||||
# JSON blob into one in-memory merge, which avoids Node heap OOMs.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
if [ ! -d coverage-shards ] || ! find coverage-shards -maxdepth 1 -type f -name '*.json' | grep -q .; then
|
||||
first_coverage_file=""
|
||||
if [ -d coverage-shards ]; then
|
||||
first_coverage_file="$(find coverage-shards -maxdepth 1 -type f -name '*.json' -print -quit)"
|
||||
fi
|
||||
if [ -z "$first_coverage_file" ]; then
|
||||
echo "::error::No raw coverage shard data was downloaded."
|
||||
find . -maxdepth 3 -type f | sort
|
||||
exit 1
|
||||
@@ -648,6 +814,7 @@ jobs:
|
||||
npx c8 report \
|
||||
--temp-directory coverage-shards \
|
||||
--reports-dir coverage \
|
||||
--merge-async \
|
||||
--reporter=text-summary \
|
||||
--reporter=json-summary \
|
||||
--exclude=tests/** \
|
||||
@@ -679,19 +846,21 @@ jobs:
|
||||
path: |
|
||||
coverage/coverage-summary.json
|
||||
coverage/coverage-report.md
|
||||
coverage/lcov.info
|
||||
if-no-files-found: warn
|
||||
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-coverage
|
||||
if: ${{ always() && needs.test-coverage.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.test-coverage.result == 'success' }}
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -715,8 +884,9 @@ jobs:
|
||||
coverage-pr-comment:
|
||||
name: PR Coverage Comment
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
|
||||
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && needs.changes.outputs.code == 'true' }}
|
||||
needs:
|
||||
- changes
|
||||
- pr-test-policy
|
||||
- test-coverage
|
||||
permissions:
|
||||
@@ -809,15 +979,17 @@ jobs:
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
OMNIROUTE_PLAYWRIGHT_SKIP_BUILD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
@@ -826,12 +998,11 @@ jobs:
|
||||
- name: Download Next.js build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: e2e-next-build
|
||||
name: next-build
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build and restore standalone node_modules
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
cp -r node_modules .build/next/standalone/node_modules
|
||||
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
|
||||
|
||||
test-integration:
|
||||
@@ -850,14 +1021,16 @@ jobs:
|
||||
DATA_DIR: /tmp/omniroute-ci-${{ matrix.shard }}
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --import tsx --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
- run: node --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
|
||||
|
||||
test-security:
|
||||
name: Security Tests
|
||||
@@ -868,31 +1041,34 @@ jobs:
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:security
|
||||
|
||||
ci-summary:
|
||||
name: CI Dashboard
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
- changes
|
||||
- lint
|
||||
- docs-sync-strict
|
||||
- i18n-ui-coverage
|
||||
- i18n
|
||||
- pr-test-policy
|
||||
|
||||
- build
|
||||
- package-artifact
|
||||
- electron-package-smoke
|
||||
- test-unit
|
||||
- node-24-compat
|
||||
- node-26-compat-build
|
||||
- node-26-compat
|
||||
- test-coverage
|
||||
- sonarqube
|
||||
@@ -929,11 +1105,11 @@ jobs:
|
||||
echo "## 🧱 Core Checks" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Change Classification | $(status '${{ needs.changes.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -943,14 +1119,15 @@ jobs:
|
||||
echo "| Build Matrix | $(status '${{ needs.build.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Package Artifact | $(status '${{ needs.package-artifact.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Electron Package Smoke | $(status '${{ needs.electron-package-smoke.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 26 Compatibility Build | $(status '${{ needs.node-26-compat-build.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "## 🧪 Tests" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Suite | Status |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Unit | $(status '${{ needs.test-unit.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 24 Compatibility | $(status '${{ needs.node-24-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 26 Compatibility | $(status '${{ needs.node-26-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 24 Compatibility Tests | $(status '${{ needs.node-24-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Node 26 Compatibility Tests | $(status '${{ needs.node-26-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Coverage | $(status '${{ needs.test-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| PR Coverage Comment | $(status '${{ needs.coverage-pr-comment.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
3
.github/workflows/claude.yml
vendored
3
.github/workflows/claude.yml
vendored
@@ -30,8 +30,9 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
|
||||
31
.github/workflows/codeql.yml
vendored
Normal file
31
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: CodeQL
|
||||
# OWNER ACTION REQUIRED before enabling auto-triggers: advanced CodeQL conflicts with
|
||||
# GitHub "default setup" — the analyze step fails with "CodeQL analyses from advanced
|
||||
# configurations cannot be processed when the default setup is enabled". Switch repo
|
||||
# Settings → Code security → CodeQL from Default to Advanced, THEN restore the
|
||||
# push/pull_request/schedule triggers below. Until then this only runs on manual dispatch
|
||||
# so it never produces a red check on PRs. (The codeqlAlerts ratchet keeps working via the
|
||||
# default setup's alerts in the meantime.)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (javascript-typescript)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
62
.github/workflows/dast-smoke.yml
vendored
Normal file
62
.github/workflows/dast-smoke.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: DAST smoke (PR)
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main", "release/**"]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
dast-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# ADVISORY while this new gate matures (repo convention: advisory -> blocking).
|
||||
# Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute
|
||||
env:
|
||||
PORT: "20128"
|
||||
INJECTION_GUARD_MODE: block
|
||||
run: |
|
||||
node dist/server.js > server.log 2>&1 &
|
||||
echo $! > server.pid
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install schemathesis
|
||||
- name: Schemathesis smoke (high-risk endpoints, blocking)
|
||||
run: |
|
||||
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
|
||||
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
|
||||
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
|
||||
--request-timeout 20 --suppress-health-check all --no-color
|
||||
- name: promptfoo injection-guard (blocking)
|
||||
env:
|
||||
OMNIROUTE_URL: http://localhost:20128
|
||||
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
|
||||
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: dast-smoke-logs
|
||||
path: server.log
|
||||
retention-days: 7
|
||||
36
.github/workflows/docker-publish.yml
vendored
36
.github/workflows/docker-publish.yml
vendored
@@ -42,8 +42,9 @@ jobs:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
# Need full tag history for semver comparison when deciding :latest.
|
||||
fetch-depth: 0
|
||||
@@ -142,8 +143,9 @@ jobs:
|
||||
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -241,8 +243,9 @@ jobs:
|
||||
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -339,10 +342,12 @@ jobs:
|
||||
output-file: sbom-image.cdx.json
|
||||
artifact-name: sbom-image.cdx.json
|
||||
|
||||
- name: Trivy image scan (advisory)
|
||||
# Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but
|
||||
# never blocks (exit-code 0). The blocking gate below narrows to CRITICAL.
|
||||
- name: Trivy image scan (SARIF, advisory)
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: aquasecurity/trivy-action@v0.36.0
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
format: sarif
|
||||
@@ -350,10 +355,29 @@ jobs:
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: "0"
|
||||
|
||||
# BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the
|
||||
# published image. Narrowed to severity CRITICAL (HIGH stays visible in the
|
||||
# SARIF step above, not blocking). ignore-unfixed:true so an unfixable base-image
|
||||
# CVE with no upstream patch does not red the release (reduces false-blocks);
|
||||
# a fixable CRITICAL still blocks. Per docs/security/SUPPLY_CHAIN.md. NB: Trivy
|
||||
# scans against a CVE DB that grows continuously — a newly-disclosed CRITICAL on
|
||||
# an unchanged base image can red this gate; the fix is to rebuild on a patched
|
||||
# base, bump the dep, or add a justified .trivyignore entry (see the CVE-variance
|
||||
# note in docs/security/SUPPLY_CHAIN.md).
|
||||
- name: Trivy CRITICAL gate (blocking)
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
format: table
|
||||
severity: CRITICAL
|
||||
ignore-unfixed: true
|
||||
exit-code: "1"
|
||||
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
22
.github/workflows/electron-release.yml
vendored
22
.github/workflows/electron-release.yml
vendored
@@ -27,8 +27,9 @@ jobs:
|
||||
version: ${{ steps.validate.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version format
|
||||
@@ -51,7 +52,7 @@ jobs:
|
||||
echo "Error: Invalid version format. Expected: v1.6.8"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "✓ Valid version: $VERSION"
|
||||
|
||||
build:
|
||||
@@ -83,7 +84,9 @@ jobs:
|
||||
deb_ext: .deb
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -91,7 +94,7 @@ jobs:
|
||||
cache: npm
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
|
||||
@@ -111,7 +114,7 @@ jobs:
|
||||
# that cause EPERM errors during Next.js standalone build glob scans.
|
||||
# Create a clean temp profile directory to avoid this.
|
||||
mkdir -p "$RUNNER_TEMP/home"
|
||||
echo "USERPROFILE=$RUNNER_TEMP/home" >> $GITHUB_ENV
|
||||
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Next.js standalone
|
||||
env:
|
||||
@@ -213,8 +216,9 @@ jobs:
|
||||
contents: write # softprops/action-gh-release creates the GitHub Release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all artifacts
|
||||
@@ -268,7 +272,11 @@ jobs:
|
||||
name: Publish to npm
|
||||
needs: [validate, release]
|
||||
permissions:
|
||||
contents: read
|
||||
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
|
||||
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
|
||||
# A reusable workflow's job cannot request more permission than the caller grants,
|
||||
# so a `read` here makes GitHub reject the run at startup (startup_failure).
|
||||
contents: write
|
||||
id-token: write # npm provenance (forwarded to the reusable workflow)
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
uses: ./.github/workflows/npm-publish.yml
|
||||
|
||||
63
.github/workflows/mutation-redundancy.yml
vendored
Normal file
63
.github/workflows/mutation-redundancy.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Mutation Redundancy (disableBail, on-demand)
|
||||
# One-off measurement to UNBLOCK R1 (test-redundancy prune). The nightly mutation run
|
||||
# (nightly-mutation.yml) bails on the first kill, so `killedBy` lists only the FIRST
|
||||
# killer — 🟠 redundant is understated and 🟢 unique overstated (see the caveat in
|
||||
# scripts/quality/mutation-radiography.mjs). This workflow re-runs the SAME combo +
|
||||
# chatCore leaf batches with stryker.disablebail.json (disableBail:true, incremental:false)
|
||||
# so `killedBy` lists EVERY killer. Feed the uploaded reports to
|
||||
# `node scripts/quality/mutation-radiography.mjs --candidates mutation-nobail-*/mutation.json`
|
||||
# to get the accurate R1 prune-candidate list (🔴 empty ∪ 🟠 redundant) for human review.
|
||||
#
|
||||
# Batches mirror the nightly's leaf decomposition (d/e/f/g/h/i) rather than 2 mega-batches:
|
||||
# disableBail is MORE expensive than bail (it never stops early), and Stryker only writes
|
||||
# mutation.json on a SUCCESSFUL finish — a batch cancelled at the cap produces NO data — so
|
||||
# smaller batches each fit the 300min headroom and run in parallel. auth/accountFallback and
|
||||
# the security quartet are out of scope: R1 targets the combo/chatCore leaves.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
stryker-nobail:
|
||||
name: Stryker disableBail (batch ${{ matrix.batch.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
batch:
|
||||
- name: d
|
||||
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
|
||||
- name: e
|
||||
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
|
||||
- name: f
|
||||
mutate: "open-sse/services/combo/quotaScoring.ts,open-sse/services/combo/quotaStrategies.ts"
|
||||
- name: g
|
||||
mutate: "open-sse/handlers/chatCore/comboContextCache.ts,open-sse/handlers/chatCore/idempotency.ts,open-sse/handlers/chatCore/passthroughHelpers.ts,open-sse/handlers/chatCore/responseHeaders.ts,open-sse/handlers/chatCore/sanitization.ts,open-sse/handlers/chatCore/upstreamTimeouts.ts"
|
||||
- name: h
|
||||
mutate: "open-sse/handlers/chatCore/headers.ts,open-sse/handlers/chatCore/logTruncation.ts,open-sse/handlers/chatCore/memoryExtraction.ts,open-sse/handlers/chatCore/nonStreamingSse.ts,open-sse/handlers/chatCore/passthroughToolNames.ts,open-sse/handlers/chatCore/executorHelpers.ts"
|
||||
- name: i
|
||||
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Run Stryker (disableBail)
|
||||
env:
|
||||
BATCH_MUTATE: ${{ matrix.batch.mutate }}
|
||||
run: npx stryker run --config-file stryker.disablebail.json --mutate "$BATCH_MUTATE"
|
||||
- name: Upload mutation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-nobail-${{ matrix.batch.name }}
|
||||
path: reports/mutation/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
10
.github/workflows/nightly-llm-security.yml
vendored
10
.github/workflows/nightly-llm-security.yml
vendored
@@ -12,7 +12,9 @@ jobs:
|
||||
name: promptfoo — injection guard (block mode, no secret)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
@@ -59,7 +61,9 @@ jobs:
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::PROMPTFOO_PROVIDER_KEY not set — skipping garak probes (advisory)."
|
||||
fi
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
- uses: actions/setup-node@v6
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
@@ -82,7 +86,7 @@ jobs:
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
with: { python-version: "3.12" }
|
||||
- run: pip install garak
|
||||
|
||||
146
.github/workflows/nightly-mutation.yml
vendored
146
.github/workflows/nightly-mutation.yml
vendored
@@ -9,29 +9,155 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stryker:
|
||||
name: Stryker mutation testing (8 critical modules — advisory)
|
||||
name: Stryker mutation (batch ${{ matrix.batch.name }} — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
# Mutation testing is expensive (~200-500 mutants, 30-90 min). It runs only
|
||||
# on the nightly schedule / manual dispatch, never on PRs. The score is NOT
|
||||
# yet enforced as a ratchet (wired in a later INT phase) — for now the job
|
||||
# just produces the HTML/JSON report and uploads it as an artifact.
|
||||
timeout-minutes: 120
|
||||
# Mutation testing is expensive. History of the budget:
|
||||
# - Full 8-module set TIMED OUT at the 180min cap (run 27705123780 = exactly 180min).
|
||||
# The two god-files chatCore.ts/combo.ts dominated ~2/3 of the mutants and were
|
||||
# removed from stryker.conf.json `mutate`.
|
||||
# - The remaining 6 modules in 3 batches: auth.ts and accountFallback.ts are large; the
|
||||
# a (auth+publicCreds) and b (accountFallback+error) batches still ran near the 180min
|
||||
# cap (the perTest dry-run over ~130 covering test files is itself costly), so the two
|
||||
# big modules are now ISOLATED into their own batches (a=auth, b=accountFallback).
|
||||
# - Onda 3 / Fase 9 T5 re-add: combo.ts was split into 11 leaves; the 8 well-covered
|
||||
# combo/* leaves are back in `mutate` (covered by the 24 combo-*.test.ts), grouped into
|
||||
# 2 batches (d=heavy, e=light). After #4204 (D7b) merged, the reset-aware quota pair
|
||||
# quotaScoring/quotaStrategies was added as batch f. A covering-test audit then added the
|
||||
# 6 chatCore/* leaves with direct unit coverage as batch g. A follow-up then wrote dedicated
|
||||
# unit tests for 6 more leaves and added them as batch h. A final follow-up added dedicated
|
||||
# tests (no mock.module — fetch-override + crafted inputs + temp-DATA_DIR) for telemetryHelpers
|
||||
# + memorySkillsInjection + semanticCache (its cache-HIT block now has a setCachedResponse
|
||||
# fixture) as batch i — ALL 15/15 chatCore leaves are now mutated. See
|
||||
# _mutate_godfiles_excluded_comment in stryker.conf.json.
|
||||
# 9 PARALLEL batches, each overriding the mutate set via `--mutate` (Stryker 9 CLI:
|
||||
# `-m, --mutate <comma-list>`; the conf's `mutate[]` remains the local-run default/union).
|
||||
# - Cold-seeding budget (per-batch `timeout-minutes: ${{ matrix.batch.timeout || 180 }}`):
|
||||
# a COLD run must COMPLETE once to write stryker-incremental.json (Stryker writes it only on
|
||||
# a successful finish); a job cancelled at the cap writes nothing, so the next run is cold
|
||||
# again — an infinite never-seeds loop. actions/cache is also branch-scoped, so each branch
|
||||
# (incl. release) must seed its OWN cache via a run with enough headroom. Measured cold runs
|
||||
# Measured cold totals (run 27801802713, extrapolated from the % at the 180/350 cancel point):
|
||||
# auth ~375min (2301 mutants — EXCEEDS the 360min job max even isolated), accountFallback
|
||||
# ~358min (1441), the c security quartet ~348min (1163), d ~197min (1316), g=142, h=132, e=66,
|
||||
# f=45, i=33. The widely-covered modules blow the budget because the tap-runner re-runs every
|
||||
# covering test file per mutant (a perTest fixed cost over ~138 test files, times thousands of
|
||||
# mutants). A flat timeout bump cannot rescue auth (>360min max) — so the three over-budget
|
||||
# batches are SPLIT so each half fits: auth->a1/a2 and accountFallback->b1/b2 by mutation range
|
||||
# (`file:startLine-endLine`), the c quartet->c1/c2 by module pair. Splitting also seeds each
|
||||
# sub-batch's own incremental cache, after which nightlies re-test only changed mutants.
|
||||
# Full coverage every night in parallel; wall-clock = the slowest batch's cold run until seeded.
|
||||
# Runs at stryker concurrency=4 with per-process DATA_DIR isolation
|
||||
# (tests/_setup/isolateDataDir.ts) — see _concurrency_comment in stryker.conf.json.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
batch:
|
||||
# Per-batch `timeout` (minutes) tiers the cold-seeding budget by measured cost; batches
|
||||
# without the key default to 180. Cold-run profiling (run 27801802713) showed the
|
||||
# widely-covered modules need FAR more than the 180 cap because the tap-runner re-runs every
|
||||
# covering test file per mutant: auth ~375min (2301 mutants, EXCEEDS the 360min GitHub job
|
||||
# max even isolated), accountFallback ~358min, the c security quartet ~348min — none fit a
|
||||
# single job. So auth/accountFallback are split by MUTATION RANGE (`file:startLine-endLine`,
|
||||
# ~half the mutants each) into a1/a2, b1/b2; the c quartet is split by MODULE pair into
|
||||
# c1/c2. d (3 combo modules, ~197min) stays whole. Split-heavy batches get 300min headroom
|
||||
# for their cold seeding run; once each batch completes once and writes
|
||||
# stryker-incremental.json, later runs re-test only changed mutants and finish far faster.
|
||||
# g/h (142/132min cold) keep a 240 buffer; e/f/i (33-66min) keep the 180 default.
|
||||
- name: a1
|
||||
mutate: "src/sse/services/auth.ts:1-1109"
|
||||
timeout: 300
|
||||
- name: a2
|
||||
mutate: "src/sse/services/auth.ts:1110-2218"
|
||||
timeout: 300
|
||||
- name: b1
|
||||
mutate: "open-sse/services/accountFallback.ts:1-863"
|
||||
timeout: 300
|
||||
- name: b2
|
||||
mutate: "open-sse/services/accountFallback.ts:864-1726"
|
||||
timeout: 300
|
||||
- name: c1
|
||||
mutate: "src/server/authz/routeGuard.ts,src/shared/utils/circuitBreaker.ts"
|
||||
timeout: 300
|
||||
- name: c2
|
||||
mutate: "open-sse/utils/error.ts,open-sse/utils/publicCreds.ts"
|
||||
timeout: 300
|
||||
- name: d
|
||||
mutate: "open-sse/services/combo/comboStructure.ts,open-sse/services/combo/autoStrategy.ts,open-sse/services/combo/validateQuality.ts"
|
||||
timeout: 300
|
||||
- name: e
|
||||
mutate: "open-sse/services/combo/shadowRouting.ts,open-sse/services/combo/targetSorters.ts,open-sse/services/combo/comboPredicates.ts,open-sse/services/combo/rrState.ts,open-sse/services/combo/comboData.ts"
|
||||
- name: f
|
||||
mutate: "open-sse/services/combo/quotaScoring.ts,open-sse/services/combo/quotaStrategies.ts"
|
||||
- name: g
|
||||
mutate: "open-sse/handlers/chatCore/comboContextCache.ts,open-sse/handlers/chatCore/idempotency.ts,open-sse/handlers/chatCore/passthroughHelpers.ts,open-sse/handlers/chatCore/responseHeaders.ts,open-sse/handlers/chatCore/sanitization.ts,open-sse/handlers/chatCore/upstreamTimeouts.ts"
|
||||
timeout: 240
|
||||
- name: h
|
||||
mutate: "open-sse/handlers/chatCore/headers.ts,open-sse/handlers/chatCore/logTruncation.ts,open-sse/handlers/chatCore/memoryExtraction.ts,open-sse/handlers/chatCore/nonStreamingSse.ts,open-sse/handlers/chatCore/passthroughToolNames.ts,open-sse/handlers/chatCore/executorHelpers.ts"
|
||||
timeout: 240
|
||||
- name: i
|
||||
mutate: "open-sse/handlers/chatCore/telemetryHelpers.ts,open-sse/handlers/chatCore/memorySkillsInjection.ts,open-sse/handlers/chatCore/semanticCache.ts"
|
||||
# Per-batch budget: split-heavy batches (a1/a2/b1/b2/c1/c2/d) override to 300min, g/h to 240min;
|
||||
# the rest default to 180min. `matrix.batch.timeout` is null for batches without the key -> `|| 180`.
|
||||
# NOTE: a1+a2 both mutate auth.ts (disjoint line ranges) and b1+b2 both mutate accountFallback.ts;
|
||||
# when merging the per-batch mutation.json for radiography/scores, same-file mutants from sibling
|
||||
# ranges must be UNIONED (scripts/check/check-mutation-ratchet.mjs::measureMutationScores and
|
||||
# scripts/quality/mutation-radiography.mjs both merge per file).
|
||||
timeout-minutes: ${{ matrix.batch.timeout || 180 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore Stryker incremental cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: reports/mutation/stryker-incremental.json
|
||||
key: stryker-incremental-${{ matrix.batch.name }}-${{ github.run_id }}
|
||||
restore-keys: stryker-incremental-${{ matrix.batch.name }}-
|
||||
- name: Run Stryker (advisory)
|
||||
id: stryker
|
||||
continue-on-error: true
|
||||
run: npx stryker run
|
||||
env:
|
||||
BATCH_MUTATE: ${{ matrix.batch.mutate }}
|
||||
run: npx stryker run --mutate "$BATCH_MUTATE"
|
||||
- name: Upload mutation report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: mutation-report
|
||||
name: mutation-report-${{ matrix.batch.name }}
|
||||
path: reports/mutation/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
# Aggregation gate (T3): each split batch emits a PARTIAL view of a mutated file
|
||||
# (auth.ts lives in a1+a2, accountFallback in b1+b2), so a PER-BATCH ratchet would
|
||||
# only ever see half a file vs the whole-file baseline. This job runs AFTER every
|
||||
# batch, downloads all reports, and ratchets the MERGED per-module scores
|
||||
# (check-mutation-ratchet UNIONS same-file mutants across reports) against the
|
||||
# dedicatedGate `mutationScore.*` floors in quality-baseline.json (seeded ~2pt below
|
||||
# the first full measurement). Blocking: a module dropping below its floor fails the
|
||||
# run. Missing reports (e.g. an artifact-upload flake) are skipped, never failed.
|
||||
mutation-ratchet:
|
||||
name: Mutation score ratchet (blocking)
|
||||
needs: stryker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Download all mutation reports
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: mutation-report-*
|
||||
path: reports/all
|
||||
- name: Ratchet merged per-module mutation scores
|
||||
run: node scripts/check/check-mutation-ratchet.mjs reports/all/*/mutation.json --ratchet
|
||||
|
||||
6
.github/workflows/nightly-property.yml
vendored
6
.github/workflows/nightly-property.yml
vendored
@@ -10,7 +10,9 @@ jobs:
|
||||
property-random-seed:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
@@ -21,7 +23,7 @@ jobs:
|
||||
run: FC_SEED=random FC_NUM_RUNS=2000 npm run test:property
|
||||
- name: Open issue on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.create({
|
||||
|
||||
137
.github/workflows/nightly-release-green.yml
vendored
Normal file
137
.github/workflows/nightly-release-green.yml
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
name: Nightly Release-Green
|
||||
|
||||
# Solution D — continuous, NON-BLOCKING drift signal for the active release branch.
|
||||
#
|
||||
# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds
|
||||
# accrue silently on release/** and explode — in layers — at release time. This
|
||||
# nightly reproduces the release-equivalent validation on the active release branch
|
||||
# HEAD and, when there are HARD failures, opens/updates a single tracking issue.
|
||||
#
|
||||
# It is NOT a required status check and never touches a contributor PR — it only
|
||||
# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is
|
||||
# expected mid-cycle and is reported but never raises the alarm on its own; only
|
||||
# real defects (typecheck / lint errors / unit / vitest / db-rules / public-creds /
|
||||
# package-artifact) flip the issue open.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Release branch to validate (default: highest release/vX.Y.Z)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: nightly-release-green
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-green:
|
||||
name: Validate active release branch
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve active release branch
|
||||
id: branch
|
||||
env:
|
||||
INPUT_BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_BRANCH:-}" ]; then
|
||||
TARGET="$INPUT_BRANCH"
|
||||
else
|
||||
# highest release/vX.Y.Z by semver among remote branches
|
||||
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
|
||||
| sed 's#origin/##' \
|
||||
| sort -t/ -k2 -V \
|
||||
| tail -1)
|
||||
fi
|
||||
if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi
|
||||
# Strict format guard — reject anything that isn't release/vX.Y.Z (blocks
|
||||
# ref/command injection via the workflow_dispatch input).
|
||||
if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Refusing non-canonical branch name: $TARGET"; exit 1
|
||||
fi
|
||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
echo "Active release branch: $TARGET"
|
||||
|
||||
- name: Checkout the release branch
|
||||
env:
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git checkout "$TARGET"
|
||||
git log -1 --oneline
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
|
||||
- name: Release-green validation (full)
|
||||
id: validate
|
||||
run: |
|
||||
set +e
|
||||
node scripts/quality/validate-release-green.mjs --json --with-build \
|
||||
1> release-green.json 2> release-green.log
|
||||
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
||||
echo "------- report -------"
|
||||
cat release-green.log
|
||||
|
||||
- name: Open / update tracking issue on HARD failure
|
||||
if: steps.validate.outputs.exit != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="🔴 Release branch not green: ${TARGET}"
|
||||
{
|
||||
echo "The nightly **release-green** validation found HARD failures on \`${TARGET}\`."
|
||||
echo "These are real defects that would block the release PR — fix them in the"
|
||||
echo "originating PR branch (via co-authorship), not by demanding it from contributors."
|
||||
echo ""
|
||||
echo "**Run:** ${RUN_URL}"
|
||||
echo ""
|
||||
echo '```'
|
||||
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) listed above is expected mid-cycle and is rebaselined at release — it is NOT a contributor concern and did not, on its own, open this issue._"
|
||||
} > issue-body.md
|
||||
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
|
||||
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
|
||||
echo "Updated existing issue #$EXISTING"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-green-report
|
||||
path: |
|
||||
release-green.json
|
||||
release-green.log
|
||||
if-no-files-found: ignore
|
||||
46
.github/workflows/nightly-resilience.yml
vendored
46
.github/workflows/nightly-resilience.yml
vendored
@@ -12,7 +12,9 @@ jobs:
|
||||
name: Heap-growth gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
@@ -24,7 +26,9 @@ jobs:
|
||||
name: Resilience chaos (fault injection)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
@@ -36,7 +40,9 @@ jobs:
|
||||
name: k6 load/soak
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
@@ -68,3 +74,37 @@ jobs:
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
|
||||
a11y:
|
||||
name: A11y axe (nightly, freeze-and-alert)
|
||||
runs-on: ubuntu-latest
|
||||
# The Playwright webServer (`start` mode) builds Next via build-next-isolated.mjs and
|
||||
# boots the standalone server itself (waits on /api/monitoring/health, 15min webServer
|
||||
# timeout). Unlike the per-PR test-e2e job, this nightly job has no pre-built artifact,
|
||||
# so it self-builds — hence the generous job timeout. REQUIRE_AXE=1 makes the suite run
|
||||
# the real axe analysis (the 4 page tests are gated to nightly so per-PR e2e stays fast)
|
||||
# and makes the meta-test fail loudly if @axe-core/playwright ever goes missing.
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
REQUIRE_AXE: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: playwright-chromium-${{ runner.os }}-
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Run axe a11y suite (self-building webServer)
|
||||
run: npx playwright test tests/e2e/a11y.spec.ts
|
||||
|
||||
10
.github/workflows/nightly-schemathesis.yml
vendored
10
.github/workflows/nightly-schemathesis.yml
vendored
@@ -13,7 +13,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
@@ -31,7 +33,7 @@ jobs:
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with: { python-version: "3.12" }
|
||||
- name: Install schemathesis
|
||||
run: pip install schemathesis
|
||||
@@ -43,7 +45,7 @@ jobs:
|
||||
# PROVE the contract is fuzzable and surface regressions, not to gate the build.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
schemathesis run docs/reference/openapi.yaml \
|
||||
schemathesis run docs/openapi.yaml \
|
||||
--url http://localhost:20128 \
|
||||
--max-examples 20 \
|
||||
--workers 4 \
|
||||
@@ -60,7 +62,7 @@ jobs:
|
||||
run: kill "$(cat server.pid)" || true
|
||||
- name: Upload schemathesis report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: schemathesis-report
|
||||
path: |
|
||||
|
||||
7
.github/workflows/npm-publish.yml
vendored
7
.github/workflows/npm-publish.yml
vendored
@@ -55,8 +55,9 @@ jobs:
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Need full tag history to compare against highest semver when
|
||||
# deciding whether this release should claim dist-tag `latest`.
|
||||
fetch-depth: 0
|
||||
@@ -201,7 +202,9 @@ jobs:
|
||||
id-token: write # npm provenance
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
8
.github/workflows/opencode-plugin-ci.yml
vendored
8
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -32,7 +32,9 @@ jobs:
|
||||
matrix:
|
||||
node: ["22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
@@ -47,7 +49,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
8
.github/workflows/opencode-provider-ci.yml
vendored
8
.github/workflows/opencode-provider-ci.yml
vendored
@@ -32,7 +32,9 @@ jobs:
|
||||
matrix:
|
||||
node: ["20", "22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
@@ -46,7 +48,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
85
.github/workflows/quality.yml
vendored
85
.github/workflows/quality.yml
vendored
@@ -27,7 +27,10 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
@@ -37,6 +40,8 @@ jobs:
|
||||
- run: npm run check:fetch-targets
|
||||
- run: npm run check:openapi-routes
|
||||
- run: npm run check:docs-symbols
|
||||
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
|
||||
run: npm run check:docs-all
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
- run: npm run check:error-helper
|
||||
@@ -46,4 +51,82 @@ jobs:
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:test-runner-api
|
||||
- run: npm run check:any-budget:t11
|
||||
- name: Typecheck (core)
|
||||
run: npm run typecheck:core
|
||||
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
|
||||
# unit tests impacted by this PR's changed files. Fail-safe runs the FULL
|
||||
# unit suite on hub/unmapped changes — TIA accelerates, never replaces, the net.
|
||||
#
|
||||
# BLOCKING (flipped 2026-06-17). The pre-existing release unit test-debt that kept
|
||||
# this advisory was cleared: #4030 (16 Zod/registry reds, lossless restore) and
|
||||
# #4063 (the last red — the LiveWS boot test — root-caused as a real event-loop
|
||||
# stall in the WS sidecar, fixed + relocated to the integration suite). A full
|
||||
# ci.yml run on release/v3.8.28 then showed all 8 unit shards green, so PR->release
|
||||
# now blocks on unit-test regressions in the impacted set (typecheck:core already
|
||||
# blocked above). Fail-safe still runs the FULL unit suite on hub/unmapped changes.
|
||||
- name: Impacted unit tests (TIA, fail-safe full; blocking)
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
|
||||
if [ -z "$SEL" ]; then echo "No source/test changes — skipping unit tests"; exit 0; fi
|
||||
# CI runners are 4-vCPU; run at --test-concurrency=4 (matching the ci.yml unit
|
||||
# job) rather than test:unit's local-tuned concurrency=20. Oversubscribing the
|
||||
# runner makes timing-sensitive tests (db-backup, upstream-timeout, ...) flake,
|
||||
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
|
||||
# run race-free regardless of concurrency.
|
||||
if echo "$SEL" | grep -q "__RUN_ALL__"; then
|
||||
echo "Fail-safe: running FULL unit suite (CI concurrency)"; npm run test:unit:ci; exit $?
|
||||
fi
|
||||
echo "Running impacted tests:"; echo "$SEL"
|
||||
mapfile -t FILES <<< "$SEL"
|
||||
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}"
|
||||
|
||||
fast-vitest:
|
||||
name: Vitest (fast-path)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:vitest
|
||||
|
||||
fast-unit:
|
||||
name: Unit Tests fast-path (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: >
|
||||
node --max-old-space-size=4096 --import tsx
|
||||
--import ./tests/_setup/isolateDataDir.ts
|
||||
--test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2
|
||||
tests/unit/*.test.ts
|
||||
"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
4
.github/workflows/scorecard.yml
vendored
4
.github/workflows/scorecard.yml
vendored
@@ -21,12 +21,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run analysis
|
||||
uses: ossf/scorecard-action@v2.4.0
|
||||
uses: ossf/scorecard-action@v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
||||
29
.github/workflows/semgrep.yml
vendored
Normal file
29
.github/workflows/semgrep.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: semgrep
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main", "release/**"]
|
||||
push:
|
||||
branches: ["main"]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
semgrep:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: semgrep/semgrep
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run semgrep (advisory)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
semgrep scan --config p/owasp-top-ten --config p/secrets \
|
||||
--sarif --output semgrep.sarif --metrics off || true
|
||||
python -c "import json; d=json.load(open('semgrep.sarif')); print('semgrepFindings=%d' % len(d['runs'][0]['results']))" || echo "semgrepFindings=SKIP"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: semgrep-sarif
|
||||
path: semgrep.sarif
|
||||
retention-days: 14
|
||||
4
.github/workflows/wiki-sync.yml
vendored
4
.github/workflows/wiki-sync.yml
vendored
@@ -37,10 +37,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -49,6 +49,10 @@ docs/new-features/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
# Also ignore a root node_modules SYMLINK (worktree setups symlink it from the main
|
||||
# checkout). The trailing-slash pattern above only matches a directory, so without this
|
||||
# a symlink named node_modules could be staged by `git add -A` and committed.
|
||||
/node_modules
|
||||
*.map
|
||||
.DS_Store
|
||||
|
||||
@@ -158,6 +162,9 @@ typescript
|
||||
# Superpowers plans/specs (internal tooling, not project code)
|
||||
docs/superpowers/
|
||||
|
||||
# TIA test-impact map — generated at runtime in CI (build-test-impact-map.mjs), never committed (~21MB)
|
||||
config/quality/test-impact-map.json
|
||||
|
||||
# GitNexus local index
|
||||
.gitnexus
|
||||
.worktrees
|
||||
|
||||
@@ -10,3 +10,4 @@ fi
|
||||
npx lint-staged
|
||||
node scripts/check/check-docs-sync.mjs
|
||||
npm run check:any-budget:t11
|
||||
node scripts/check/check-tracked-artifacts.mjs
|
||||
|
||||
99
.vscode/settings.json
vendored
99
.vscode/settings.json
vendored
@@ -19,30 +19,79 @@
|
||||
}
|
||||
},
|
||||
"git.ignoreLimitWarning": true,
|
||||
|
||||
// ─── Git: não adicionar os ~44 repos aninhados (9 worktrees + _references/* +
|
||||
// _mono_repo/* + _ideia/_tasks/.agents) ao Source Control. Era a causa do
|
||||
// "validando muito": um git status + watcher por repo. Só o repo raiz fica. ───
|
||||
"git.autoRepositoryDetection": false,
|
||||
"git.repositoryScanMaxDepth": 0,
|
||||
"git.detectSubmodules": false,
|
||||
"git.autofetch": false,
|
||||
"git.autorefresh": true,
|
||||
|
||||
// ─── Performance: não seguir o symlink de node_modules criado pelas worktrees ───
|
||||
// As worktrees em .worktrees/ apontam node_modules para o checkout principal;
|
||||
// sem isto a busca atravessa o symlink mesmo com node_modules excluído.
|
||||
"search.followSymlinks": false,
|
||||
|
||||
// ─── TypeScript server (monorepo grande: ~6,6k arquivos rastreados, Next.js 16) ───
|
||||
"typescript.tsserver.maxTsServerMemory": 4096,
|
||||
"typescript.disableAutomaticTypeAcquisition": true,
|
||||
"typescript.tsc.autoDetect": "off",
|
||||
"npm.autoDetect": "off",
|
||||
// O tsserver tem o próprio watcher (independente de files.watcherExclude).
|
||||
// excludeDirectories evita que ele vigie as árvores pesadas.
|
||||
"typescript.tsserver.watchOptions": {
|
||||
"excludeDirectories": [
|
||||
"**/node_modules",
|
||||
"**/.next",
|
||||
"**/.build",
|
||||
"**/dist",
|
||||
"**/coverage",
|
||||
"**/.worktrees"
|
||||
]
|
||||
},
|
||||
|
||||
// Para esconder os diretórios gerados da árvore do Explorer, descomente:
|
||||
// "files.exclude": {
|
||||
// "**/.worktrees": true,
|
||||
// "**/coverage": true,
|
||||
// "**/dist": true,
|
||||
// "**/.build": true,
|
||||
// "**/.next": true,
|
||||
// "**/_references": true,
|
||||
// "**/_mono_repo": true,
|
||||
// "**/electron": true,
|
||||
// "**/node_modules": true,
|
||||
// "**/.next": true,
|
||||
// "**/coverage": true,
|
||||
// "**/omniroute-*.tgz": true,
|
||||
// "**/_tasks": true
|
||||
// "**/_tasks": true,
|
||||
// "**/omniroute-*.tgz": true
|
||||
// },
|
||||
"files.watcherExclude": {
|
||||
"**/_references/**": true,
|
||||
"**/_mono_repo/**": true,
|
||||
"**/electron/**": true,
|
||||
"**/.git/objects/**": true,
|
||||
"**/.git/subtree-cache/**": true,
|
||||
"**/node_modules/**": true,
|
||||
"**/.next/**": true,
|
||||
"**/coverage/**": true,
|
||||
"**/_tasks/**": true,
|
||||
"**/.git/objects/**": true,
|
||||
"**/.build/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/build/**": true,
|
||||
"**/out/**": true,
|
||||
"**/coverage/**": true,
|
||||
"**/.coverage/**": true,
|
||||
"**/.nyc_output/**": true,
|
||||
"**/.cache/**": true,
|
||||
"**/.turbo/**": true,
|
||||
"**/.swc/**": true,
|
||||
"**/.stryker-tmp/**": true,
|
||||
"**/stryker-output-*/**": true,
|
||||
"**/playwright-report/**": true,
|
||||
"**/test-results/**": true,
|
||||
"**/.worktrees/**": true,
|
||||
"**/.claude/worktrees/**": true,
|
||||
"**/electron/**": true,
|
||||
"**/_references/**": true,
|
||||
"**/_mono_repo/**": true,
|
||||
"**/_tasks/**": true,
|
||||
"**/logs/**": true,
|
||||
"**/*.tgz": true,
|
||||
"**/*.tsbuildinfo": true,
|
||||
"**/OmniRoute-*/**": true,
|
||||
"**/*-merge-*/**": true,
|
||||
"**/*-worktree-*/**": true,
|
||||
@@ -50,16 +99,32 @@
|
||||
"**/*-reorg*/**": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/_references": true,
|
||||
"**/_mono_repo": true,
|
||||
"**/electron": true,
|
||||
"**/node_modules": true,
|
||||
"**/.next": true,
|
||||
"**/coverage": true,
|
||||
"**/_tasks": true,
|
||||
"**/.build": true,
|
||||
"**/dist": true,
|
||||
"**/build": true,
|
||||
"**/out": true,
|
||||
"**/coverage": true,
|
||||
"**/.coverage": true,
|
||||
"**/.nyc_output": true,
|
||||
"**/.cache": true,
|
||||
"**/.turbo": true,
|
||||
"**/.swc": true,
|
||||
"**/.stryker-tmp": true,
|
||||
"**/stryker-output-*": true,
|
||||
"**/playwright-report": true,
|
||||
"**/test-results": true,
|
||||
"**/.worktrees": true,
|
||||
"**/.claude/worktrees": true,
|
||||
"**/electron": true,
|
||||
"**/_references": true,
|
||||
"**/_mono_repo": true,
|
||||
"**/_tasks": true,
|
||||
"**/logs": true,
|
||||
"**/*.tgz": true,
|
||||
"**/*.tsbuildinfo": true,
|
||||
"**/package-lock.json": true,
|
||||
"**/OmniRoute-*": true,
|
||||
"**/*-merge-*": true,
|
||||
"**/*-worktree-*": true,
|
||||
|
||||
31
.zizmor.yml
31
.zizmor.yml
@@ -71,3 +71,34 @@ rules:
|
||||
# Accepted risk, re-review at the next release cycle (added 2026-06-15).
|
||||
ignore:
|
||||
- deploy-vps.yml
|
||||
|
||||
cache-poisoning:
|
||||
# zizmor's cache-poisoning audit flags `actions/setup-node` (cache: npm) and
|
||||
# `actions/cache` steps that populate a cache which a later artifact-publishing
|
||||
# job could consume — the attack is: a fork PR seeds a poisoned cache that a
|
||||
# trusted publish run then restores and ships. None of the four entries below
|
||||
# are exploitable in that way:
|
||||
#
|
||||
# • electron-release.yml / npm-publish.yml — PUBLISH workflows that trigger
|
||||
# ONLY on trusted events: `push: tags v*` + `workflow_dispatch` (electron)
|
||||
# and `release: [released]` + `workflow_dispatch` + `workflow_call`
|
||||
# (npm). They NEVER run on a `pull_request` from a fork, so an untrusted
|
||||
# ref can never populate the npm/node_modules cache they restore. The
|
||||
# cache key is `hashFiles('package-lock.json')` / `runner.os-node-…`, a
|
||||
# first-party, lockfile-pinned key on trusted events.
|
||||
#
|
||||
# • opencode-plugin-ci.yml / opencode-provider-ci.yml — CI-only workflows
|
||||
# (lint/test of the two opencode packages). They DO run on `pull_request`,
|
||||
# but (a) they publish NO artifacts — there is no downstream publish job
|
||||
# that restores their cache, and (b) GitHub Actions cache is branch-scoped:
|
||||
# a fork-PR run writes to a PR-isolated cache that base-branch / release
|
||||
# runs cannot read. The `cache: npm` here only speeds up `npm ci` within
|
||||
# the same ephemeral CI run.
|
||||
#
|
||||
# Accepted risk (first-party caching on trusted/isolated events), re-review at
|
||||
# the next release cycle (added 2026-06-16).
|
||||
ignore:
|
||||
- electron-release.yml
|
||||
- npm-publish.yml
|
||||
- opencode-plugin-ci.yml
|
||||
- opencode-provider-ci.yml
|
||||
|
||||
@@ -240,10 +240,18 @@ export function resolveOmniRoutePluginOptions(
|
||||
opts?: OmniRoutePluginOptions
|
||||
): Required<Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">> &
|
||||
Pick<OmniRoutePluginOptions, "baseURL" | "features"> {
|
||||
const providerId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY;
|
||||
const rawProviderId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY;
|
||||
// OC 1.17.8+ native-adapter gate rejects providerID not in
|
||||
// {openai, anthropic, opencode*}. Silently prefix so existing
|
||||
// configs (providerId: "omniroute") keep working.
|
||||
const providerId = rawProviderId.startsWith("opencode-")
|
||||
? rawProviderId
|
||||
: `opencode-${rawProviderId}`;
|
||||
const displayName =
|
||||
opts?.displayName ??
|
||||
(providerId === OMNIROUTE_PROVIDER_KEY ? "OmniRoute" : `OmniRoute (${providerId})`);
|
||||
(providerId === `opencode-${OMNIROUTE_PROVIDER_KEY}`
|
||||
? "OmniRoute"
|
||||
: `OmniRoute (${providerId})`);
|
||||
const modelCacheTtl =
|
||||
typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0
|
||||
? opts.modelCacheTtl
|
||||
@@ -708,7 +716,14 @@ export function mapRawModelToModelV2(
|
||||
const outMods = new Set(raw.output_modalities ?? ["text"]);
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
// OC's static-catalog reader parses the key on `/` to recover
|
||||
// `(providerID, modelID)`. If the raw id is already provider-prefixed
|
||||
// (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or
|
||||
// `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave
|
||||
// it as-is — double-prefixing breaks OC's lookup. Otherwise prefix with
|
||||
// the resolved `providerId` so a bare key like `claude-opus-4` parses as
|
||||
// `(omniroute, claude-opus-4)` and the credentials resolve correctly.
|
||||
id: raw.id.includes("/") ? raw.id : `${ctx.providerId}/${raw.id}`,
|
||||
/**
|
||||
* Display name. Falls back to raw.id when no enrichment is available;
|
||||
* the caller (`createOmniRouteProviderHook`) overlays
|
||||
@@ -1166,6 +1181,10 @@ export function mapAutoComboToStaticEntry(
|
||||
typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0
|
||||
? autoCombo.max_output_tokens
|
||||
: AUTO_COMBO_FALLBACK_OUTPUT;
|
||||
// No `providerID` field on static-catalog entries — OC ignores it on the static
|
||||
// path, and stamping it on auto-combos but not on raw/combo entries was an
|
||||
// internal inconsistency. The dynamic-hook path builds its ModelV2 from the
|
||||
// individual fields below and never read this field either.
|
||||
return {
|
||||
name,
|
||||
attachment: false,
|
||||
@@ -2248,26 +2267,38 @@ export function slugifyComboName(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a combo's static-block key (`combo/<slug>`), guaranteeing uniqueness
|
||||
* across an entire static catalog. If `<slug>` is already present in `used`,
|
||||
* suffixes a short UUID-prefix disambiguator from `combo.id` so the second
|
||||
* combo doesn't silently overwrite the first. Mutates `used` in place by
|
||||
* recording the chosen key. Returns the final `combo/<...>` key.
|
||||
* Build a combo's static-block key, provider-prefixed as `<providerId>/<slug>`
|
||||
* (e.g. `omniroute/MASTER`, `omniroute/MASTER-LIGHT`), guaranteeing uniqueness
|
||||
* across an entire static catalog. If `<providerId>/<slug>` is already present in
|
||||
* `used`, suffixes a short UUID-prefix disambiguator from `combo.id` so the second
|
||||
* combo doesn't silently overwrite the first. Mutates `used` in place by recording
|
||||
* the chosen key. Returns the final `<providerId>/<slug>` key.
|
||||
*
|
||||
* Falls back to `combo/<id>` when the friendly name slugifies to the empty
|
||||
* NOTE: the key MUST carry the OWNING provider prefix (`omniroute/…`), never a
|
||||
* `combo/` namespace — OpenCode parses model IDs on `/` to extract the provider,
|
||||
* so `combo/MASTER` would resolve provider=`combo` (no credentials) and fail with
|
||||
* "Unable to determine provider", whereas `omniroute/MASTER` resolves provider=
|
||||
* `omniroute` and the openai-compatible adapter strips the prefix and sends the
|
||||
* bare slug upstream, which the server resolves via getComboByName. See PR #4184.
|
||||
*
|
||||
* Falls back to `<providerId>/<id>` when the friendly name slugifies to the empty
|
||||
* string (e.g. a combo named just punctuation).
|
||||
*/
|
||||
export function buildComboKey(combo: OmniRouteRawCombo, used: Set<string>): string {
|
||||
export function buildComboKey(
|
||||
combo: OmniRouteRawCombo,
|
||||
used: Set<string>,
|
||||
providerId: string
|
||||
): string {
|
||||
const friendlyName = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
|
||||
let slug = slugifyComboName(friendlyName);
|
||||
if (slug.length === 0) slug = combo.id;
|
||||
let key = `combo/${slug}`;
|
||||
let key = `${providerId}/${slug}`;
|
||||
if (used.has(key)) {
|
||||
const tail = combo.id.split("-")[0] ?? combo.id;
|
||||
key = `combo/${slug}-${tail}`;
|
||||
key = `${providerId}/${slug}-${tail}`;
|
||||
// Defensive: in the (impossible) event the disambiguated key also
|
||||
// collides, append the full id.
|
||||
if (used.has(key)) key = `combo/${slug}-${combo.id}`;
|
||||
if (used.has(key)) key = `${providerId}/${slug}-${combo.id}`;
|
||||
}
|
||||
used.add(key);
|
||||
return key;
|
||||
@@ -2651,7 +2682,12 @@ export function createOmniRouteProviderHook(
|
||||
);
|
||||
applyProviderTag(model, tagEntry);
|
||||
}
|
||||
models[entry.id] = model;
|
||||
// OC's static-catalog reader parses the key on `/` to recover
|
||||
// (providerID, modelID). `mapRawModelToModelV2` already stamps the
|
||||
// prefixed id on `model.id` (e.g. `omniroute/claude-primary`), so we
|
||||
// must key by `model.id` — not by the raw `entry.id` which would be
|
||||
// a bare slug and parse as `providerID=slug, modelID=""`.
|
||||
models[model.id] = model;
|
||||
}
|
||||
|
||||
// Default compression combo (used to decorate ALL combo names when
|
||||
@@ -2801,18 +2837,6 @@ export function createOmniRouteProviderHook(
|
||||
// models with curated names).
|
||||
applyEnrichment(mapped, rawEnrichment.get(combo.id));
|
||||
|
||||
// `Combo: ` prefix surfaces the combo nature in OC's model picker.
|
||||
// Idempotent guard covers the case where enrichment overwrote
|
||||
// mapped.name with an already-prefixed string. Mirrors the
|
||||
// static-hook Combo:-prefix decoration.
|
||||
if (!mapped.name.startsWith("Combo: ")) {
|
||||
mapped.name = `Combo: ${mapped.name}`;
|
||||
}
|
||||
|
||||
// Optionally decorate combo name with its compression pipeline.
|
||||
// Only fires when features.compressionMetadata: true, OmniRoute
|
||||
// returned at least one default compression combo, AND the
|
||||
// combo has resolvable members — claiming compression on an
|
||||
// unroutable combo would mislead the picker.
|
||||
if (hasMembers && defaultCompression && defaultCompression.pipeline.length > 0) {
|
||||
const tag = formatCompressionPipeline(defaultCompression.pipeline);
|
||||
@@ -2821,18 +2845,37 @@ export function createOmniRouteProviderHook(
|
||||
}
|
||||
}
|
||||
|
||||
const comboKey = buildComboKey(combo, usedComboKeys);
|
||||
const comboKey = buildComboKey(combo, usedComboKeys, resolved.providerId);
|
||||
|
||||
// Collision policy: combos win. Warn ONCE per (cacheKey, comboKey)
|
||||
// when overwriting a same-key raw model so the operator can spot
|
||||
// the unusual naming choice without log spam.
|
||||
// the unusual naming choice without log spam. Suppress the warning
|
||||
// when the collision is the intentional dedup pattern (combo.name
|
||||
// exactly matches an existing raw model's id) — /v1/models
|
||||
// pre-mirrors combos as raw entries and the operator's intent is
|
||||
// always "combo wins" in that case.
|
||||
if (Object.prototype.hasOwnProperty.call(models, comboKey)) {
|
||||
const dedupeKey = `${cacheKey}::${comboKey}`;
|
||||
if (!collisionWarned.has(dedupeKey)) {
|
||||
collisionWarned.add(dedupeKey);
|
||||
console.warn(
|
||||
`[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.`
|
||||
);
|
||||
const existing = models[comboKey];
|
||||
// Intentional dedup: `/v1/models` pre-mirrors combos as raw
|
||||
// entries, so the bare combo name appears as the model id in
|
||||
// `rawModels`. After our prefixing the existing entry's id is
|
||||
// `${providerId}/${raw.id}` — the combo name is a substring of
|
||||
// that prefixed id (or, for already-prefixed raw models, the
|
||||
// exact id). Use `endsWith` to avoid matching substrings of
|
||||
// unrelated prefixed ids.
|
||||
const isIntentionalDedup =
|
||||
existing &&
|
||||
combo.name &&
|
||||
combo.name.trim().length > 0 &&
|
||||
(existing.id === combo.name.trim() || existing.id.endsWith(`/${combo.name.trim()}`));
|
||||
if (!isIntentionalDedup) {
|
||||
const dedupeKey = `${cacheKey}::${comboKey}`;
|
||||
if (!collisionWarned.has(dedupeKey)) {
|
||||
collisionWarned.add(dedupeKey);
|
||||
console.warn(
|
||||
`[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
models[comboKey] = mapped;
|
||||
@@ -3346,8 +3389,18 @@ function normaliseModalities(raw: unknown): OmniRouteModalityKind[] {
|
||||
}
|
||||
|
||||
export interface OmniRouteStaticModelEntry {
|
||||
/** Owning provider id. SHOULD match the parent `provider.<id>` key so OC's
|
||||
* static-catalog reader resolves credentials via `providerID` instead of
|
||||
* parsing the model key on `/`. Optional: OC's schema validator may
|
||||
* reject the entire provider block when this field is present but the
|
||||
* model KEY already carries the provider prefix (e.g. `omniroute/MASTER`),
|
||||
* since the prefix makes the field redundant and the field is not part of
|
||||
* OC's expected schema. We omit it from entries and rely on the prefix
|
||||
* on the KEY alone. See PR #4184. */
|
||||
providerID?: string;
|
||||
/** Display label rendered in OC's model picker. Defaults to the model id. */
|
||||
name: string;
|
||||
|
||||
/** ISO date the model was released. Surfaces in OC's model card when present. */
|
||||
release_date?: string;
|
||||
/** Model accepts image / file attachments. */
|
||||
@@ -3545,6 +3598,12 @@ export function buildStaticProviderEntry(
|
||||
if (!displayName.startsWith(prefix)) displayName = `${prefix}${displayName}`;
|
||||
}
|
||||
}
|
||||
// OC's static-catalog schema doesn't expect a `providerID` field on
|
||||
// individual entries — the parent block ID is the provider. Adding
|
||||
// unknown fields here can cause OC's schema validator to reject the
|
||||
// entire provider block, hiding ALL models. The provider prefix on the
|
||||
// model KEY (e.g. `omniroute/claude-opus-4`) is what OC uses to recover
|
||||
// (providerID, modelID) when the user selects a model.
|
||||
const entry: OmniRouteStaticModelEntry = { name: displayName };
|
||||
|
||||
const attachment = caps.attachment ?? caps.vision;
|
||||
@@ -3608,7 +3667,12 @@ export function buildStaticProviderEntry(
|
||||
entry.release_date = raw.release_date;
|
||||
}
|
||||
|
||||
models[raw.id] = entry;
|
||||
// OC's static-catalog reader parses each key on `/` and rejects the
|
||||
// entire provider block if ANY key resolves to a parsed providerID that
|
||||
// has no corresponding provider block. So bare keys (no `/`) MUST be
|
||||
// prefixed with the resolved providerId. Already-prefixed keys
|
||||
// (e.g. `cc/claude-opus-4-7`) are left as-is to avoid double-prefixing.
|
||||
models[raw.id.includes("/") ? raw.id : `${opts.providerId}/${raw.id}`] = entry;
|
||||
}
|
||||
|
||||
// Combo entries → stripped LCD shape. Each combo is keyed as
|
||||
@@ -3717,12 +3781,11 @@ export function buildStaticProviderEntry(
|
||||
const hasMembers = memberEntries.length > 0;
|
||||
const friendlyName =
|
||||
combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
|
||||
// `Combo: ` prefix surfaces the combo nature in OC's model picker — the
|
||||
// catalog key (`combo/<slug>`) is already namespaced, but the picker
|
||||
// shows `name`, so prefix the display string too.
|
||||
const prefixedName = `Combo: ${friendlyName}`;
|
||||
const displayName =
|
||||
hasMembers && compressionSuffix ? `${prefixedName}${compressionSuffix}` : prefixedName;
|
||||
hasMembers && compressionSuffix ? `${friendlyName} ${compressionSuffix}` : friendlyName;
|
||||
// See the raw-model entry comment above — `providerID` on entries is
|
||||
// not part of OC's static-catalog schema; the parent block ID is the
|
||||
// provider and the KEY prefix (`omniroute/<slug>`) is what OC parses.
|
||||
const entry: OmniRouteStaticModelEntry = { name: displayName };
|
||||
|
||||
if (hasMembers) {
|
||||
@@ -3790,12 +3853,12 @@ export function buildStaticProviderEntry(
|
||||
entry.tool_call = false;
|
||||
}
|
||||
|
||||
// Key under `combo/<slug>` (e.g. `combo/claude-primary`) so the
|
||||
// namespace cleanly separates combos from raw provider/model pairs
|
||||
// and so the key is copy/paste-friendly. Slug collisions across
|
||||
// Key under bare slug (e.g. `claude-primary`) — no `combo/` prefix
|
||||
// because OpenCode parses model IDs on `/` and would treat
|
||||
// `combo/MASTER` as provider=`combo`. Slug collisions across
|
||||
// combos are disambiguated with a short UUID-prefix suffix; see
|
||||
// `buildComboKey` for the policy.
|
||||
models[buildComboKey(combo, usedComboKeys)] = entry;
|
||||
models[buildComboKey(combo, usedComboKeys, opts.providerId)] = entry;
|
||||
|
||||
// Make this combo's resolved entry available to parent combos
|
||||
// that reference it via combo-ref. Use the friendly name since
|
||||
@@ -4343,8 +4406,24 @@ export function createOmniRouteConfigHook(
|
||||
authJson = undefined;
|
||||
}
|
||||
|
||||
const entry = authJson?.[resolved.providerId] as AuthJsonApiEntry | undefined;
|
||||
const apiKey = entry && entry.type === "api" && typeof entry.key === "string" ? entry.key : "";
|
||||
// Try both prefixed (e.g. opencode-omniroute) and unprefixed (e.g. omniroute)
|
||||
// keys so a user who ran `/connect omniroute` before the auto-prefix fix
|
||||
// does not need to re-auth. Also handles dual-key for auth.json entries
|
||||
// written by a newer OC dispatcher with the prefixed key.
|
||||
const bareKey = resolved.providerId.startsWith("opencode-")
|
||||
? resolved.providerId.slice("opencode-".length)
|
||||
: resolved.providerId;
|
||||
const lookupKeys = [resolved.providerId];
|
||||
if (bareKey !== resolved.providerId) lookupKeys.push(bareKey);
|
||||
let entry;
|
||||
for (const k of lookupKeys) {
|
||||
const e = authJson?.[k];
|
||||
if (e?.type === "api" && typeof e.key === "string" && e.key.length > 0) {
|
||||
entry = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const apiKey = entry?.type === "api" && typeof entry.key === "string" ? entry.key : "";
|
||||
|
||||
if (!apiKey) {
|
||||
// (c) no apiKey — silent no-op (with debug breadcrumb). The operator
|
||||
|
||||
@@ -13,12 +13,12 @@ import { createOmniRouteAuthHook } from "../src/index.js";
|
||||
|
||||
test("createOmniRouteAuthHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.equal(hook.provider, "omniroute");
|
||||
assert.equal(hook.provider, "opencode-omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: custom providerId binds to hook.provider (multi-instance)", () => {
|
||||
const hook = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(hook.provider, "omniroute-preprod");
|
||||
assert.equal(hook.provider, "opencode-omniroute-preprod");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: methods[0] is type 'api' with label including displayName", () => {
|
||||
@@ -30,7 +30,7 @@ test("createOmniRouteAuthHook: methods[0] is type 'api' with label including dis
|
||||
assert.equal(m.label, "OmniRoute API Key");
|
||||
|
||||
const custom = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (omniroute-preprod) API Key");
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (opencode-omniroute-preprod) API Key");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: prompts[0] uses key='apiKey' per @opencode-ai/plugin contract", () => {
|
||||
|
||||
@@ -447,14 +447,14 @@ test("models() returns combo entries merged into the map", async () => {
|
||||
|
||||
// 3 raw models + 1 combo = 4 entries
|
||||
assert.equal(Object.keys(out).length, 4);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["claude-secondary"]);
|
||||
assert.ok(out["gemini-3-flash"]);
|
||||
assert.ok(out["combo/claude-tier"]);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-secondary"]);
|
||||
assert.ok(out["opencode-omniroute/gemini-3-flash"]);
|
||||
assert.ok(out["opencode-omniroute/claude-tier"]);
|
||||
|
||||
const combo = out["combo/claude-tier"];
|
||||
assert.equal(combo.name, "Combo: Claude Tier");
|
||||
assert.equal(combo.providerID, "omniroute");
|
||||
const combo = out["opencode-omniroute/claude-tier"];
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.providerID, "opencode-omniroute");
|
||||
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
|
||||
assert.equal(combo.limit.context, 100_000);
|
||||
assert.equal(combo.capabilities.reasoning, false);
|
||||
@@ -478,11 +478,11 @@ test("models(): combo with unknown member ids degrades to all-false LCD posture"
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["combo/phantom-combo"]);
|
||||
assert.ok(out["opencode-omniroute/phantom-combo"]);
|
||||
// With zero resolvable members, LCD = all-false (defensive posture).
|
||||
assert.equal(out["combo/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["combo/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["combo/phantom-combo"].limit.context, 0);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].limit.context, 0);
|
||||
});
|
||||
|
||||
test("models(): hidden combos are excluded from the map", async () => {
|
||||
@@ -505,11 +505,11 @@ test("models(): hidden combos are excluded from the map", async () => {
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["combo/visible"]);
|
||||
assert.ok(!out["combo/hidden"], "hidden combo must be omitted");
|
||||
assert.ok(out["opencode-omniroute/visible"]);
|
||||
assert.ok(!out["opencode-omniroute/hidden"], "hidden combo must be omitted");
|
||||
});
|
||||
|
||||
test("models(): combo name exactly matches raw model id → raw deleted, combo lives at combo/ key, no warn", async () => {
|
||||
test("models(): combo name exactly matches raw model id → raw deleted, raw deleted, no warn", async () => {
|
||||
// Combo.name === raw model id triggers the dedup deletion. This mirrors
|
||||
// the real OmniRoute payload where /v1/models pre-mirrors combos as
|
||||
// no-slash raw entries whose ids match /api/combos friendly names.
|
||||
@@ -529,10 +529,9 @@ test("models(): combo name exactly matches raw model id → raw deleted, combo l
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
|
||||
// Raw model deleted by combo-name dedup; combo surfaces under combo/<slug>.
|
||||
assert.equal(out["claude-primary"], undefined, "raw deleted by combo-name dedup");
|
||||
assert.ok(out["combo/claude-primary"], "combo surfaces under combo/ namespace");
|
||||
assert.equal(out["combo/claude-primary"].name, "Combo: claude-primary");
|
||||
// Raw model replaced by combo of the same key; combo now lives at the bare slug.
|
||||
assert.ok(out["opencode-omniroute/claude-primary"], "combo surfaces under prefixed key");
|
||||
assert.equal(out["opencode-omniroute/claude-primary"].name, "claude-primary");
|
||||
|
||||
// No collision warning fires — dedup makes keys disjoint.
|
||||
const collisionWarns = warnings.filter((w) => {
|
||||
@@ -543,7 +542,7 @@ test("models(): combo name exactly matches raw model id → raw deleted, combo l
|
||||
});
|
||||
|
||||
test("models(): two combos with same slug → second gets disambiguator suffix", async () => {
|
||||
// Both combos slug to `claude` — second must get `combo/claude-<id-prefix>`.
|
||||
// Both combos slug to `claude` — second must get `claude-<id-prefix>`.
|
||||
const combos: OmniRouteRawCombo[] = [
|
||||
{
|
||||
id: "uuid-a",
|
||||
@@ -566,8 +565,8 @@ test("models(): two combos with same slug → second gets disambiguator suffix",
|
||||
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
// First combo gets the bare slug; second gets disambiguated.
|
||||
assert.ok(out["combo/claude"], "first combo at bare slug");
|
||||
assert.ok(out["combo/claude-uuid"], "second combo disambiguated by id prefix");
|
||||
assert.ok(out["opencode-omniroute/claude"], "first combo at prefixed slug");
|
||||
assert.ok(out["opencode-omniroute/claude-uuid"], "second combo disambiguated by id prefix");
|
||||
});
|
||||
|
||||
test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => {
|
||||
@@ -584,8 +583,8 @@ test("models(): combos fetch fails → falls back to models-only, warn emitted,
|
||||
|
||||
// Catalog includes the models but NOT any combo entries.
|
||||
assert.equal(Object.keys(out).length, 2);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["claude-secondary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-secondary"]);
|
||||
|
||||
// Soft-fail warning surfaced.
|
||||
const softFail = warnings.find((w) => {
|
||||
@@ -610,7 +609,7 @@ test("models(): combos cached + reused within TTL (one combo fetch per TTL windo
|
||||
const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL");
|
||||
assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL");
|
||||
assert.ok(second["combo/claude-tier"]);
|
||||
assert.ok(second["opencode-omniroute/claude-tier"]);
|
||||
});
|
||||
|
||||
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
|
||||
@@ -702,7 +701,7 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
const masterLight = out["combo/master-light"];
|
||||
const masterLight = out["opencode-omniroute/master-light"];
|
||||
assert.ok(masterLight, "MASTER-LIGHT entry must exist");
|
||||
assert.equal(
|
||||
masterLight.limit.context,
|
||||
|
||||
@@ -203,7 +203,7 @@ function makeInput(initialProvider: Record<string, unknown> = {}): Config {
|
||||
|
||||
test("config: with valid auth.json + apiKey + baseURL → mutates input.provider[id] with stripped models block", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test-1", baseURL: "https://or.example.com/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -217,8 +217,8 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider.omniroute;
|
||||
assert.ok(entry, "input.provider.omniroute set");
|
||||
const entry = provider["opencode-omniroute"];
|
||||
assert.ok(entry, "input.provider['opencode-omniroute'] set");
|
||||
assert.equal(entry.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(entry.name, "OmniRoute");
|
||||
assert.equal(entry.options.baseURL, "https://or.example.com/v1");
|
||||
@@ -227,7 +227,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
// Stripped per-model shape: name + cap flags + modalities + (optional)
|
||||
// cost. OC's SDK static schema accepts only `limit.{context,output}` —
|
||||
// `limit.input` is NOT in the SDK shape and gets dropped silently.
|
||||
const claude = entry.models["claude-sonnet-4-6"];
|
||||
const claude = entry.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.ok(claude, "claude model surfaced");
|
||||
assert.equal(claude.name, "claude-sonnet-4-6");
|
||||
assert.equal(claude.attachment, true);
|
||||
@@ -246,16 +246,75 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
assert.deepEqual(claude.modalities?.input, ["text", "image"]);
|
||||
assert.deepEqual(claude.modalities?.output, ["text"]);
|
||||
|
||||
// Combo surfaces under `combo/<friendly-name>` namespace + LCD'd
|
||||
// Combo surfaces under bare key + LCD'd
|
||||
// (gemini's reasoning=false → combo reasoning=false).
|
||||
const combo = entry.models["combo/claude-tier"];
|
||||
assert.ok(combo, "combo surfaced under combo/ namespace");
|
||||
assert.equal(combo.name, "Combo: Claude Tier");
|
||||
const combo = entry.models["opencode-omniroute/claude-tier"];
|
||||
assert.ok(combo, "combo surfaced under bare key");
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false");
|
||||
assert.equal(combo.tool_call, true);
|
||||
assert.equal(combo.limit?.context, 200_000, "LCD: min(200_000, 1_000_000)");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 1b. Dual-key fallback (#5027) — auth.json stored under the BARE providerId
|
||||
// (pre-auto-prefix login) must still resolve when the active providerId is
|
||||
// prefixed (`opencode-omniroute`). Without the fallback the lookup misses
|
||||
// the stored key and the user is forced to re-auth.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: auth.json under bare key (pre-prefix login) resolves via dual-key fallback", async () => {
|
||||
// Stored under bare `omniroute` (the key OC wrote before the auto-prefix fix),
|
||||
// but the resolved providerId is now `opencode-omniroute`.
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-bare-1", baseURL: "https://or.example.com/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" }, // resolves to opencode-omniroute internally
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
const entry = provider["opencode-omniroute"];
|
||||
assert.ok(entry, "provider entry published from bare-key apiKey");
|
||||
assert.equal(entry.options.apiKey, "sk-bare-1", "apiKey resolved from the bare auth.json key");
|
||||
assert.equal(entry.options.baseURL, "https://or.example.com/v1");
|
||||
});
|
||||
|
||||
test("config: prefixed key wins over bare key when both present (dual-key precedence)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-prefixed", baseURL: "https://pref.example/v1" },
|
||||
omniroute: { type: "api", key: "sk-bare", baseURL: "https://bare.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(
|
||||
entry.options.apiKey,
|
||||
"sk-prefixed",
|
||||
"prefixed key takes precedence (looked up first)"
|
||||
);
|
||||
assert.equal(entry.options.baseURL, "https://pref.example/v1");
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Missing auth.json → no-op, no throw, no mutation
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -323,7 +382,7 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
models: { "manual-model": { name: "manual-model" } },
|
||||
};
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -333,11 +392,11 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
{ providerId: "omniroute" },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput({ omniroute: manual });
|
||||
const input = makeInput({ "opencode-omniroute": manual });
|
||||
await hook(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, unknown> }).provider;
|
||||
assert.equal(provider.omniroute, manual, "manual override preserved by reference");
|
||||
assert.equal(provider["opencode-omniroute"], manual, "manual override preserved by reference");
|
||||
assert.equal(fetcher.callCount(), 0, "no fetch — short-circuited before I/O");
|
||||
assert.equal(readAuthJson.callCount(), 0, "no auth.json read either");
|
||||
assert.ok(
|
||||
@@ -352,7 +411,7 @@ test("config: existing input.provider[id] → no overwrite (respect manual overr
|
||||
|
||||
test("config: fetchers throw → warn + emit stub entry with models: {}", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = throwingCombosFetcher();
|
||||
@@ -368,8 +427,9 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry, "stub provider entry published even when fetchers fail");
|
||||
assert.equal(entry.npm, "@ai-sdk/openai-compatible");
|
||||
assert.deepEqual(entry.models, {}, "models stub is empty object");
|
||||
@@ -392,7 +452,7 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
|
||||
|
||||
test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = throwingCombosFetcher();
|
||||
@@ -405,12 +465,16 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
const ids = Object.keys(entry.models).sort();
|
||||
assert.deepEqual(ids, ["claude-sonnet-4-6", "gemini-3-flash"]);
|
||||
assert.equal(entry.models["combo-claude-tier"], undefined, "no combo entry");
|
||||
assert.deepEqual(ids, [
|
||||
"opencode-omniroute/claude-sonnet-4-6",
|
||||
"opencode-omniroute/gemini-3-flash",
|
||||
]);
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"], undefined, "no combo entry");
|
||||
assert.ok(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"combos-fetch breadcrumb emitted"
|
||||
@@ -423,7 +487,7 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
|
||||
test("config: baseURL from auth.json takes precedence when opts.baseURL absent", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -437,14 +501,15 @@ test("config: baseURL from auth.json takes precedence when opts.baseURL absent",
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://creds.example/v1");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.options.baseURL, "https://creds.example/v1");
|
||||
});
|
||||
|
||||
test("config: opts.baseURL wins over auth.json's stored baseURL", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://creds.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -458,14 +523,15 @@ test("config: opts.baseURL wins over auth.json's stored baseURL", async () => {
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://opts.example/v1");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.options.baseURL, "https://opts.example/v1");
|
||||
});
|
||||
|
||||
test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test" }, // NO baseURL on the credential
|
||||
"opencode-omniroute": { type: "api", key: "sk-test" }, // NO baseURL on the credential
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -493,12 +559,12 @@ test("config: no baseURL resolvable (no opts, no auth.json baseURL) → no-op",
|
||||
|
||||
test("config: multi-instance — two plugins with different providerIds publish to their own keys without collision", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"omniroute-prod": {
|
||||
"opencode-omniroute-prod": {
|
||||
type: "api",
|
||||
key: "sk-prod",
|
||||
baseURL: "https://prod.example/v1",
|
||||
},
|
||||
"omniroute-preprod": {
|
||||
"opencode-omniroute-preprod": {
|
||||
type: "api",
|
||||
key: "sk-preprod",
|
||||
baseURL: "https://preprod.example/v1",
|
||||
@@ -522,15 +588,18 @@ test("config: multi-instance — two plugins with different providerIds publish
|
||||
await hookB(input);
|
||||
|
||||
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
|
||||
assert.ok(provider["omniroute-prod"], "prod block present");
|
||||
assert.ok(provider["omniroute-preprod"], "preprod block present");
|
||||
assert.equal(provider["omniroute-prod"].options.apiKey, "sk-prod");
|
||||
assert.equal(provider["omniroute-preprod"].options.apiKey, "sk-preprod");
|
||||
assert.equal(provider["omniroute-prod"].options.baseURL, "https://prod.example/v1");
|
||||
assert.equal(provider["omniroute-preprod"].options.baseURL, "https://preprod.example/v1");
|
||||
assert.ok(provider["opencode-omniroute-prod"], "prod block present");
|
||||
assert.ok(provider["opencode-omniroute-preprod"], "preprod block present");
|
||||
assert.equal(provider["opencode-omniroute-prod"].options.apiKey, "sk-prod");
|
||||
assert.equal(provider["opencode-omniroute-preprod"].options.apiKey, "sk-preprod");
|
||||
assert.equal(provider["opencode-omniroute-prod"].options.baseURL, "https://prod.example/v1");
|
||||
assert.equal(
|
||||
provider["opencode-omniroute-preprod"].options.baseURL,
|
||||
"https://preprod.example/v1"
|
||||
);
|
||||
assert.notEqual(
|
||||
provider["omniroute-prod"],
|
||||
provider["omniroute-preprod"],
|
||||
provider["opencode-omniroute-prod"],
|
||||
provider["opencode-omniroute-preprod"],
|
||||
"blocks are distinct references"
|
||||
);
|
||||
});
|
||||
@@ -542,7 +611,7 @@ test("config: multi-instance — two plugins with different providerIds publish
|
||||
|
||||
test("config + provider share cache: second call uses cached fetch result (single fetch per TTL)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -574,7 +643,7 @@ test("config + provider share cache: second call uses cached fetch result (singl
|
||||
|
||||
test("provider → config order also dedupes (cache populated by provider, consumed by config)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-reverse", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -638,6 +707,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro
|
||||
"cost",
|
||||
"limit",
|
||||
"modalities",
|
||||
"providerID",
|
||||
]);
|
||||
for (const [id, entry] of Object.entries(block.models)) {
|
||||
for (const key of Object.keys(entry)) {
|
||||
@@ -653,7 +723,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro
|
||||
}
|
||||
|
||||
// Sanity: claude entry has all expected stripped fields.
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(typeof claude.name, "string");
|
||||
assert.equal(typeof claude.attachment, "boolean");
|
||||
assert.equal(typeof claude.reasoning, "boolean");
|
||||
@@ -678,8 +748,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["combo-claude-tier"], undefined);
|
||||
assert.ok(block.models["claude-sonnet-4-6"]);
|
||||
assert.equal(block.models["opencode-omniroute/claude-tier"], undefined);
|
||||
assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -695,7 +765,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.deepEqual(claude.modalities?.input, ["text", "image"]);
|
||||
assert.deepEqual(claude.modalities?.output, ["text"]);
|
||||
});
|
||||
@@ -709,7 +779,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", ()
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal((claude.limit as Record<string, unknown>).input, undefined);
|
||||
assert.equal(typeof claude.limit?.context, "number");
|
||||
assert.equal(typeof claude.limit?.output, "number");
|
||||
@@ -737,7 +807,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", ()
|
||||
"sk-test",
|
||||
enrichment
|
||||
);
|
||||
const claude = block.models["claude-sonnet-4-6"];
|
||||
const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(claude.cost?.input, 3);
|
||||
assert.equal(claude.cost?.output, 15);
|
||||
assert.equal(claude.cost?.cache_read, 0.3);
|
||||
@@ -758,8 +828,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["gemini-3-flash"].release_date, undefined);
|
||||
assert.equal(block.models["opencode-omniroute/claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["opencode-omniroute/gemini-3-flash"].release_date, undefined);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => {
|
||||
@@ -788,7 +858,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const combo = block.models["combo/mixed-tier"];
|
||||
const combo = block.models["opencode-omniroute/mixed-tier"];
|
||||
assert.ok(combo, "combo emitted under slug key");
|
||||
// claude has text+image, text-only has text → intersection drops image.
|
||||
assert.deepEqual(combo.modalities?.input, ["text"]);
|
||||
@@ -812,7 +882,7 @@ test("OmniRoutePlugin factory exposes config hook alongside auth + provider", as
|
||||
|
||||
test("config: auth.json entry of wrong type (oauth) → no-op", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "oauth", refresh: "r", access: "a", expires: 0 },
|
||||
"opencode-omniroute": { type: "oauth", refresh: "r", access: "a", expires: 0 },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -849,7 +919,7 @@ test("config: readAuthJson throws → treat as missing file (silent fallback)",
|
||||
|
||||
test("config: initialises input.provider when undefined", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -864,7 +934,7 @@ test("config: initialises input.provider when undefined", async () => {
|
||||
await hook(input);
|
||||
const provider = (input as { provider?: Record<string, unknown> }).provider;
|
||||
assert.ok(provider, "provider bag initialised");
|
||||
assert.ok(provider!.omniroute);
|
||||
assert.ok(provider!["opencode-omniroute"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -874,7 +944,7 @@ test("config: initialises input.provider when undefined", async () => {
|
||||
|
||||
test("config: enrichment fetched + name overlaid on raw-model entries", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -893,19 +963,20 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini 3 Flash");
|
||||
// Combo names still come from /api/combos — enrichment overlay does NOT touch combos.
|
||||
assert.equal(entry.models["combo/claude-tier"].name, "Combo: Claude Tier");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(enrichmentFetcher.callCount(), 1);
|
||||
});
|
||||
|
||||
test("config: features.enrichment=false skips enrichment fetch + keeps raw-id names", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -923,16 +994,21 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
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["opencode-omniroute/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 () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -946,10 +1022,15 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"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["opencode-omniroute/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")),
|
||||
@@ -990,7 +1071,7 @@ const MODEL_NV_LLAMA: OmniRouteRawModelEntry = {
|
||||
|
||||
test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CC_OPUS, MODEL_NV_LLAMA]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1012,8 +1093,9 @@ test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"], "claude kept");
|
||||
assert.ok(entry.models["nvidia/llama-3-70b"], "nvidia kept (filter off)");
|
||||
assert.equal(providersFetcher.callCount(), 0, "providers fetch not called when feature off");
|
||||
@@ -1021,7 +1103,7 @@ test("config: usableOnly=false → no filter (existing behavior)", async () => {
|
||||
|
||||
test("config: usableOnly=true → drops models for non-usable providers, keeps usable + unknown", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([
|
||||
MODEL_CC_OPUS,
|
||||
@@ -1061,8 +1143,9 @@ test("config: usableOnly=true → drops models for non-usable providers, keeps u
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"], "claude kept (active)");
|
||||
assert.equal(entry.models["nvidia/llama-3-70b"], undefined, "nvidia dropped (error status)");
|
||||
assert.ok(entry.models["agentrouter/synthetic-1"], "unknown prefix kept (subtract-filter)");
|
||||
@@ -1071,7 +1154,7 @@ test("config: usableOnly=true → drops models for non-usable providers, keeps u
|
||||
|
||||
test("config: usableOnly=true + providers fetch fails → soft-fail keeps everything", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CC_OPUS, MODEL_NV_LLAMA]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1092,8 +1175,9 @@ test("config: usableOnly=true + providers fetch fails → soft-fail keeps everyt
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry.models["cc/claude-opus-4-7"]);
|
||||
assert.ok(entry.models["nvidia/llama-3-70b"], "soft-fail keeps both");
|
||||
assert.ok(
|
||||
@@ -1104,7 +1188,7 @@ test("config: usableOnly=true + providers fetch fails → soft-fail keeps everyt
|
||||
|
||||
test("config: diskCache hydrates stale snapshot when /v1/models throws", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1141,11 +1225,15 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.ok(entry.models["claude-sonnet-4-6"], "stale snapshot hydrated into static block");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"stale snapshot hydrated into static block"
|
||||
);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6 (cached)",
|
||||
"stale enrichment also reused"
|
||||
);
|
||||
@@ -1158,7 +1246,7 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
|
||||
test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1190,9 +1278,10 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
await configHook(input);
|
||||
assert.equal(enrichmentFetcher.callCount(), 1, "config reused cached enrichment");
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1203,7 +1292,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
|
||||
test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-model names", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE, MODEL_GEMINI]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
@@ -1238,18 +1327,25 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
assert.equal(entry.models["gemini-3-flash"].name, "Gemini-cli - Gemini 3 Flash");
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
assert.equal(
|
||||
entry.models["opencode-omniroute/gemini-3-flash"].name,
|
||||
"Gemini-cli - Gemini 3 Flash"
|
||||
);
|
||||
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
|
||||
assert.equal(entry.models["combo/claude-tier"].name, "Combo: Claude Tier");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
|
||||
});
|
||||
|
||||
test("config: providerTag=false suppresses the suffix", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1267,10 +1363,11 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6",
|
||||
"enriched name kept, provider tag suppressed"
|
||||
);
|
||||
@@ -1278,7 +1375,7 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
|
||||
test("config: providerTag falls back to UPPER(alias) when providerDisplayName missing", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1299,14 +1396,15 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1325,14 +1423,15 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
});
|
||||
|
||||
test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
omniroute: { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
@@ -1351,16 +1450,24 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
|
||||
const inputA = makeInput();
|
||||
await hook(inputA);
|
||||
const entryA = (inputA as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entryA.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
const entryA = (inputA as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryA.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
|
||||
// Second invocation (cache hit) — name must still be single-suffixed.
|
||||
const inputB = makeInput();
|
||||
await hook(inputB);
|
||||
const entryB = (inputB as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider
|
||||
.omniroute;
|
||||
assert.equal(entryB.models["claude-sonnet-4-6"].name, "Claude - Claude Sonnet 4.6");
|
||||
const entryB = (inputB as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryB.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -1412,7 +1519,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros
|
||||
);
|
||||
// Pre-fix: Parent would advertise 200_000 (only raw-big counted).
|
||||
// Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck).
|
||||
const parent = block.models["combo/parent"];
|
||||
const parent = block.models["opencode-omniroute/parent"];
|
||||
assert.ok(parent, "Parent combo must be in the static catalog");
|
||||
assert.equal(parent.limit?.context, 8_000);
|
||||
});
|
||||
|
||||
@@ -376,7 +376,7 @@ test("provider hook: enrichment fetcher called when features.enrichment !== fals
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "enrichment fetcher called once");
|
||||
const m = out["claude-sonnet-4-6"];
|
||||
const m = out["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied");
|
||||
assert.equal(m.cost.input, 3, "enrichment pricing applied");
|
||||
assert.equal(m.cost.output, 15);
|
||||
@@ -401,7 +401,11 @@ test("provider hook: enrichment fetcher NOT called when features.enrichment:fals
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 0, "enrichment fetcher NOT called when gated off");
|
||||
assert.equal(out["claude-sonnet-4-6"].name, "claude-sonnet-4-6", "raw id preserved");
|
||||
assert.equal(
|
||||
out["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id preserved"
|
||||
);
|
||||
});
|
||||
|
||||
test("provider hook: compression metadata fetcher NOT called by default (opt-in)", async () => {
|
||||
@@ -459,7 +463,7 @@ test("provider hook: compression metadata fetcher called when opted in", async (
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "compression metadata fetcher called");
|
||||
const combo = out["combo/claude-primary"];
|
||||
const combo = out["opencode-omniroute/claude-primary"];
|
||||
assert.ok(combo, "combo entry present");
|
||||
assert.match(
|
||||
combo.name,
|
||||
@@ -473,7 +477,7 @@ test("provider hook: compression metadata fetcher called when opted in", async (
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const stubAuthJson = (apiKey: string) => async () => ({
|
||||
omniroute: { type: "api" as const, key: apiKey },
|
||||
"opencode-omniroute": { type: "api" as const, key: apiKey },
|
||||
});
|
||||
|
||||
test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => {
|
||||
@@ -488,7 +492,7 @@ test("config hook: MCP auto-emit OFF by default (no mcp entry)", async () => {
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
assert.ok(input.provider?.omniroute, "provider block written");
|
||||
assert.ok(input.provider?.["opencode-omniroute"], "provider block written");
|
||||
assert.equal(input.mcp, undefined, "no mcp block written");
|
||||
});
|
||||
|
||||
@@ -508,7 +512,7 @@ test("config hook: features.mcpAutoEmit:true writes mcp entry with provider apiK
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.omniroute as
|
||||
const entry = input.mcp?.["opencode-omniroute"] as
|
||||
| { type: string; url: string; enabled: boolean; headers: Record<string, string> }
|
||||
| undefined;
|
||||
assert.ok(entry, "mcp entry written");
|
||||
@@ -538,7 +542,7 @@ test("config hook: features.mcpToken overrides provider apiKey in mcp Bearer", a
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.omniroute as { headers: Record<string, string> };
|
||||
const entry = input.mcp?.["opencode-omniroute"] as { headers: Record<string, string> };
|
||||
assert.equal(
|
||||
entry.headers.Authorization,
|
||||
"Bearer sk-mcp-narrower",
|
||||
@@ -561,11 +565,11 @@ test("config hook: existing operator mcp.<providerId> wins (no overwrite)", asyn
|
||||
}
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {
|
||||
mcp: { omniroute: { type: "custom-user-entry", url: "https://manual.example/mcp" } },
|
||||
mcp: { "opencode-omniroute": { type: "custom-user-entry", url: "https://manual.example/mcp" } },
|
||||
};
|
||||
await hook(input as never);
|
||||
assert.deepEqual(
|
||||
input.mcp?.omniroute,
|
||||
input.mcp?.["opencode-omniroute"],
|
||||
{ type: "custom-user-entry", url: "https://manual.example/mcp" },
|
||||
"operator override preserved"
|
||||
);
|
||||
@@ -580,7 +584,7 @@ test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips corr
|
||||
},
|
||||
{
|
||||
readAuthJson: async () => ({
|
||||
"omniroute-preprod": { type: "api" as const, key: "sk-preprod" },
|
||||
"opencode-omniroute-preprod": { type: "api" as const, key: "sk-preprod" },
|
||||
}),
|
||||
fetcher: async () => SAMPLE_RAW,
|
||||
combosFetcher: async () => [],
|
||||
@@ -589,7 +593,7 @@ test("config hook: features.mcpAutoEmit:true with /v1 in baseURL → strips corr
|
||||
);
|
||||
const input: { provider?: Record<string, unknown>; mcp?: Record<string, unknown> } = {};
|
||||
await hook(input as never);
|
||||
const entry = input.mcp?.["omniroute-preprod"] as { url: string };
|
||||
const entry = input.mcp?.["opencode-omniroute-preprod"] as { url: string };
|
||||
assert.equal(
|
||||
entry.url,
|
||||
"https://or-preprod.example.com/api/mcp/stream",
|
||||
|
||||
@@ -38,8 +38,8 @@ test("multi-instance: two plugin invocations bind to their own providerId", asyn
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
assert.equal(a.auth?.provider, "omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "omniroute-preprod");
|
||||
assert.equal(a.auth?.provider, "opencode-omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "opencode-omniroute-preprod");
|
||||
});
|
||||
|
||||
test("multi-instance: hook objects + nested arrays are independent references", async () => {
|
||||
@@ -70,8 +70,8 @@ test("multi-instance: identical opts twice still yield independent objects", asy
|
||||
assert.notEqual(first.auth, second.auth);
|
||||
assert.notEqual(first.auth?.methods, second.auth?.methods);
|
||||
// Same provider id is fine — what matters is no shared mutable state.
|
||||
assert.equal(first.auth?.provider, "twin");
|
||||
assert.equal(second.auth?.provider, "twin");
|
||||
assert.equal(first.auth?.provider, "opencode-twin");
|
||||
assert.equal(second.auth?.provider, "opencode-twin");
|
||||
});
|
||||
|
||||
test("multi-instance: mutating instance A's auth.methods does not affect instance B", async () => {
|
||||
@@ -132,5 +132,5 @@ test("multi-instance: invalid opts on one instance does not poison the other", a
|
||||
providerId: "recovered",
|
||||
baseURL: "https://ok.example/v1",
|
||||
});
|
||||
assert.equal(ok.auth?.provider, "recovered");
|
||||
assert.equal(ok.auth?.provider, "opencode-recovered");
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ const apiAuth = (key: string, baseURL?: string): unknown =>
|
||||
|
||||
test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] });
|
||||
assert.equal(hook.id, "omniroute");
|
||||
assert.equal(hook.id, "opencode-omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => {
|
||||
@@ -87,8 +87,8 @@ test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-ins
|
||||
{ providerId: "omniroute-local" },
|
||||
{ combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(a.id, "omniroute-preprod");
|
||||
assert.equal(b.id, "omniroute-local");
|
||||
assert.equal(a.id, "opencode-omniroute-preprod");
|
||||
assert.equal(b.id, "opencode-omniroute-local");
|
||||
});
|
||||
|
||||
test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => {
|
||||
@@ -101,7 +101,7 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -152,11 +152,13 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
const claude = out["claude-primary"];
|
||||
const claude = out["opencode-omniroute/claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
assert.equal(claude.id, "claude-primary");
|
||||
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
||||
// static-catalog reader resolves `(providerID, modelID)` from the key.
|
||||
assert.equal(claude.id, "opencode-omniroute/claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.providerID, "opencode-omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
assert.equal(claude.api.url, "https://or.example.com/v1");
|
||||
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
OmniRoutePlugin,
|
||||
OMNIROUTE_PROVIDER_KEY,
|
||||
@@ -27,7 +26,7 @@ test("scaffold: default export is v1 plugin shape { id, server: OmniRoutePlugin
|
||||
|
||||
test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
const r = resolveOmniRoutePluginOptions();
|
||||
assert.equal(r.providerId, "omniroute");
|
||||
assert.equal(r.providerId, "opencode-omniroute");
|
||||
assert.equal(r.displayName, "OmniRoute");
|
||||
assert.equal(r.modelCacheTtl, 300_000);
|
||||
assert.equal(r.baseURL, undefined);
|
||||
@@ -35,8 +34,8 @@ test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
|
||||
test("resolveOmniRoutePluginOptions: custom providerId derives displayName", () => {
|
||||
const r = resolveOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
|
||||
assert.equal(r.providerId, "omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (omniroute-preprod)");
|
||||
assert.equal(r.providerId, "opencode-omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (opencode-omniroute-preprod)");
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: explicit displayName wins", () => {
|
||||
@@ -63,11 +62,12 @@ test("OmniRoutePlugin: returns an empty hooks object (scaffold)", async () => {
|
||||
assert.notEqual(hooks, null);
|
||||
});
|
||||
|
||||
test("scaffold: CJS default export resolves via require() with v1 shape", () => {
|
||||
const require_ = createRequire(import.meta.url);
|
||||
const cjs = require_("../dist/index.cjs");
|
||||
// after cjsInterop:true, default export is on cjs.default
|
||||
assert.strictEqual(typeof cjs.default, "object");
|
||||
assert.strictEqual(cjs.default.id, "@omniroute/opencode-plugin");
|
||||
assert.strictEqual(typeof cjs.default.server, "function");
|
||||
test("scaffold: built ESM default export resolves with the v1 plugin shape", async () => {
|
||||
// The plugin is ESM-only now — the CJS bundle was dropped to fix the OpenCode
|
||||
// loader (#3883), so there is no more ../dist/index.cjs. Validate that the built
|
||||
// distributable's default export still carries the OpenCode v1 { id, server } shape.
|
||||
const mod = await import("../dist/index.js");
|
||||
assert.strictEqual(typeof mod.default, "object");
|
||||
assert.strictEqual(mod.default.id, "@omniroute/opencode-plugin");
|
||||
assert.strictEqual(typeof mod.default.server, "function");
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
## Project
|
||||
|
||||
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
|
||||
with **226 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
with **231 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
|
||||
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
|
||||
with **MCP Server** (87 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
|
||||
> **Live counts (v3.8.24)**: providers 226 · MCP tools 87 · MCP scopes 30 · A2A skills 6 ·
|
||||
> **Live counts (v3.8.31)**: providers 231 · MCP tools 87 · MCP scopes 30 · A2A skills 6 ·
|
||||
> open-sse services 115 · routing strategies 15 · auto-combo scoring factors 9 ·
|
||||
> DB modules 83 · DB migrations 97 · base tables 17 · search providers 11 ·
|
||||
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
|
||||
@@ -554,12 +554,13 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
|
||||
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
|
||||
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/reference/openapi.yaml`](docs/reference/openapi.yaml) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/openapi.yaml`](docs/openapi.yaml) |
|
||||
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
|
||||
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
|
||||
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
|
||||
| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
|
||||
| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
849
CHANGELOG.md
849
CHANGELOG.md
@@ -4,6 +4,845 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.8.36] — 2026-06-25
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
**Quota-Share system**
|
||||
|
||||
- **feat(quota):** introduce a dedicated `quota-share` combo strategy (Fase 3 #9) — Deficit Round Robin scheduling with per-model in-flight gating (P2C), automatic DB migration that promotes existing `qtSd/*` combos, and per-policy gating so invalid allocations cannot bleed `allow` to unintended connections. ([#4939](https://github.com/diegosouzapw/OmniRoute/pull/4939), [#4901](https://github.com/diegosouzapw/OmniRoute/pull/4901))
|
||||
- **feat(quota):** multi-window usage buckets, per-(key,model) caps, and session stickiness — connections now track consumption across 5 h, 7 d, and per-model windows; `quota_allocation_model_caps` enforces per-key/model limits; session stickiness preserves prompt-cache integrity across turns. ([#4928](https://github.com/diegosouzapw/OmniRoute/pull/4928), [#4927](https://github.com/diegosouzapw/OmniRoute/pull/4927), [#4929](https://github.com/diegosouzapw/OmniRoute/pull/4929))
|
||||
- **feat(quota):** headroom strategy + proactive saturation — new `headroom` combo strategy selects connections by available quota margin; universal proactive saturation via upstream token-usage response headers; real Claude quota saturation sourced from `/api/oauth/usage`. ([#4908](https://github.com/diegosouzapw/OmniRoute/pull/4908), [#4907](https://github.com/diegosouzapw/OmniRoute/pull/4907), [#4885](https://github.com/diegosouzapw/OmniRoute/pull/4885))
|
||||
- **feat(quota):** concurrency control + cooldown-wait (Fase 2.1) — `max_concurrent` is enforced at dispatch time; quota-share combos queue concurrent requests with a short cooldown-wait and re-dispatch on slot availability (Variant A); a cron heal proactively restores connections after their window resets. ([#4965](https://github.com/diegosouzapw/OmniRoute/pull/4965), [#4970](https://github.com/diegosouzapw/OmniRoute/pull/4970), [#4967](https://github.com/diegosouzapw/OmniRoute/pull/4967), [#4900](https://github.com/diegosouzapw/OmniRoute/pull/4900))
|
||||
|
||||
**Combo routing**
|
||||
|
||||
- **feat(combo):** task-aware routing strategy — routes requests to the best-fit connection based on task-type metadata, enabling per-task provider specialization within a combo. ([#4945](https://github.com/diegosouzapw/OmniRoute/pull/4945))
|
||||
- **feat(combo):** Fusion strategy (16th strategy) — fan out to a configurable panel of models in parallel, then synthesize results through a judge model. ([#4652](https://github.com/diegosouzapw/OmniRoute/pull/4652))
|
||||
- **feat(combos):** add an editable per-combo `description` field. The routing-combo form now has a Description input, persisted in the combo `data` blob via `/api/combos` (POST/PUT) and round-tripped through GET — no new DB column. ([#5005](https://github.com/diegosouzapw/OmniRoute/issues/5005))
|
||||
- **feat(routing):** honor `X-Route-Model` request header to override `body.model`, enabling per-request model switching without modifying the request body. ([#4863](https://github.com/diegosouzapw/OmniRoute/pull/4863) — thanks @costaeder)
|
||||
|
||||
**Providers & models**
|
||||
|
||||
- **feat(providers):** update volcengine-ark model list, adding DeepSeek-V4-Flash and DeepSeek-V4-Pro. ([#4905](https://github.com/diegosouzapw/OmniRoute/pull/4905) — thanks @kenlin8827)
|
||||
- **feat(provider):** add CodeBuddy CN (`copilot.tencent.com`) — full OAuth + executor + model catalog stack. ([#4664](https://github.com/diegosouzapw/OmniRoute/pull/4664))
|
||||
- **feat(opencode-go):** advertise `glm-5.2` and `kimi-k2.7-code` to align with official Go endpoints. ([#4711](https://github.com/diegosouzapw/OmniRoute/pull/4711))
|
||||
- **feat(sse):** add Google Flow video-generation provider. ([#4769](https://github.com/diegosouzapw/OmniRoute/pull/4769))
|
||||
- **feat(api/v1):** include alias-backed models in the `/v1/models` listing. ([#4630](https://github.com/diegosouzapw/OmniRoute/pull/4630))
|
||||
|
||||
**Proxy pool**
|
||||
|
||||
- **feat(proxy-pool):** Cloudflare Workers proxy deployer + pool integration — deploy Cloudflare Workers relays directly from the dashboard and register them in the proxy pool. ([#4640](https://github.com/diegosouzapw/OmniRoute/pull/4640))
|
||||
- **feat(proxy-pool):** Deno Deploy relays + group action buttons — deploy Deno Deploy relay workers and manage proxy groups with new bulk-action controls. ([#4643](https://github.com/diegosouzapw/OmniRoute/pull/4643))
|
||||
|
||||
**Compression & infrastructure**
|
||||
|
||||
- **feat(compression):** Kiro/CodeWhisperer tool-result compression engine — dedicated compressor for Kiro/CodeWhisperer tool outputs integrated into the streaming pipeline. ([#4635](https://github.com/diegosouzapw/OmniRoute/pull/4635))
|
||||
- **feat(endpoint):** per-endpoint custom system prompt injection. A toggle + text field in the Endpoint settings card lets users inject a custom system prompt into every model request, applied via the existing system-prompt engine. Stored in settings DB. ([#5022](https://github.com/diegosouzapw/OmniRoute/pull/5022) — thanks @whale9820)
|
||||
- **feat(live-ws):** allow non-loopback clients via `LIVE_WS_ALLOWED_HOSTS` env var, enabling multi-host setups to access the live WebSocket API. ([#4877](https://github.com/diegosouzapw/OmniRoute/pull/4877) — thanks @KooshaPari)
|
||||
- **feat(db):** track API endpoint dimension on `usage_history` for per-endpoint cost and usage analytics. ([#4676](https://github.com/diegosouzapw/OmniRoute/pull/4676))
|
||||
|
||||
---
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
**Translator**
|
||||
|
||||
- **fix(translator):** regroup parallel tool results to be adjacent to their originating assistant turn, fixing tool-message ordering for providers that require strict interleaving. ([#4882](https://github.com/diegosouzapw/OmniRoute/pull/4882))
|
||||
- **fix(translator):** preserve literal empty-string tool arguments in OpenAI-to-Claude streaming — they were previously dropped, causing tool calls to arrive with missing parameters. ([#4959](https://github.com/diegosouzapw/OmniRoute/pull/4959))
|
||||
- **fix(translator):** normalize tools to Anthropic-native shape for non-Anthropic providers, ensuring tool definitions pass validation regardless of the format at the call site. ([#4650](https://github.com/diegosouzapw/OmniRoute/pull/4650))
|
||||
- **fix(translator):** provider thinking compatibility — correct thinking-block serialization for DeepSeek and Gemini providers. ([#4946](https://github.com/diegosouzapw/OmniRoute/pull/4946))
|
||||
- **fix(translator):** emit `</think>` close marker for Anthropic thinking blocks, fixing truncated reasoning output in streamed responses. ([#4633](https://github.com/diegosouzapw/OmniRoute/pull/4633))
|
||||
- **fix(translator):** normalize `developer` role to `system` for OpenAI-format providers. ([#4625](https://github.com/diegosouzapw/OmniRoute/pull/4625))
|
||||
- **fix(translator):** strip top-level `client_metadata` on the OpenAI passthrough path (port from 9router#1157). ([#4624](https://github.com/diegosouzapw/OmniRoute/pull/4624))
|
||||
- **fix(translator):** replay `reasoning_content` on plain Xiaomi MiMo turns (port from 9router#1321). ([#4639](https://github.com/diegosouzapw/OmniRoute/pull/4639))
|
||||
|
||||
**Copilot / GitHub executor**
|
||||
|
||||
- **fix(copilot):** never route Gemini/Claude model variants to the `/responses` endpoint — these models require the chat-completions path only. ([#4627](https://github.com/diegosouzapw/OmniRoute/pull/4627))
|
||||
- **fix(github):** route Copilot Codex models to `/responses` (port from 9router#102). ([#4626](https://github.com/diegosouzapw/OmniRoute/pull/4626))
|
||||
- **fix(copilot,antigravity):** cap `maxOutputTokens` at 16384 to stop "Invalid Argument" 400 errors on high-token requests. ([#4636](https://github.com/diegosouzapw/OmniRoute/pull/4636))
|
||||
- **fix(codex):** drop non-standard `codex.*` streaming events that break `responses.stream` consumers. ([#4715](https://github.com/diegosouzapw/OmniRoute/pull/4715) — thanks @jeffer1312)
|
||||
|
||||
**Claude / Anthropic**
|
||||
|
||||
- **fix(claude):** omit `adaptive_thinking` and `output_config.effort` for Haiku model variants, which reject those parameters. ([#4661](https://github.com/diegosouzapw/OmniRoute/pull/4661))
|
||||
- **fix(claude):** skip `mcp__` tool-name cloaking and guard against missing `connectionId` to prevent crashes on Claude-native MCP tool calls. ([#4861](https://github.com/diegosouzapw/OmniRoute/pull/4861) — thanks @costaeder)
|
||||
- **fix(claude-oauth):** respect `429` backoff headers on the Claude OAuth usage endpoint to reduce polling spam during quota checks. ([#4655](https://github.com/diegosouzapw/OmniRoute/pull/4655))
|
||||
|
||||
**Routing & SSE**
|
||||
|
||||
- **fix(sse):** fail over on `400` responses that carry rate-limit text in the body, not just on canonical `429` status codes. ([#4986](https://github.com/diegosouzapw/OmniRoute/pull/4986))
|
||||
- **fix(sse):** honor per-account proxy and fingerprint-rotation settings in the opencode executor. ([#4989](https://github.com/diegosouzapw/OmniRoute/pull/4989))
|
||||
- **fix(sse):** soft-penalize exhausted providers in auto-combo scoring instead of hard-excluding them, improving fallback resilience. ([#4990](https://github.com/diegosouzapw/OmniRoute/pull/4990))
|
||||
- **fix(sse):** drop the CCP pin when the pinned provider is durably unhealthy, with anti-flap logic to prevent oscillation. ([#4864](https://github.com/diegosouzapw/OmniRoute/pull/4864) — thanks @costaeder)
|
||||
- **fix(combo):** fetch models dynamically from custom provider endpoints instead of relying on a static list. ([#4860](https://github.com/diegosouzapw/OmniRoute/pull/4860))
|
||||
- **fix(combo):** propagate the selected connection ID to fallback error responses so model lockout applies to the correct connection rather than the wrong fallback target. ([#4809](https://github.com/diegosouzapw/OmniRoute/pull/4809) — thanks @Chewji9875)
|
||||
- **fix(sse):** skip third-party tool-name cloaking for Anthropic-native server tools to prevent naming conflicts. ([#4808](https://github.com/diegosouzapw/OmniRoute/pull/4808) — thanks @NomenAK)
|
||||
|
||||
**Quota**
|
||||
|
||||
- **fix(quota):** quota-exclusive `qtSd/*` connections now appear in `/v1/models` listings; EPSILON-threshold check no longer falsely blocks under-budget allocations. ([#4830](https://github.com/diegosouzapw/OmniRoute/pull/4830))
|
||||
- **fix(quota):** migration 107 correctly activates the `quota-share` strategy on existing `qtSd/*` combos. ([#4962](https://github.com/diegosouzapw/OmniRoute/pull/4962))
|
||||
|
||||
**API / responses**
|
||||
|
||||
- **fix(api):** parse the `/v1/responses` request body once instead of 3–4 times on the hot path, reducing per-request overhead. ([#4958](https://github.com/diegosouzapw/OmniRoute/pull/4958))
|
||||
- **fix(api):** evict stale in-memory rate-limit windows to stop a slow heap leak on long-running instances. ([#4957](https://github.com/diegosouzapw/OmniRoute/pull/4957))
|
||||
- **fix(api):** require authentication on the compression `run-telemetry` endpoint; document `OMNIROUTE_EVAL_CREDENTIALS` env var. ([#4796](https://github.com/diegosouzapw/OmniRoute/pull/4796))
|
||||
- **fix(api):** stop `GET /api/system/env/repair` returning HTTP `500` on packaged installs (it broke the onboarding wizard). `createRequire(import.meta.url)` ran at module top-level; once webpack bundles the route into the standalone build, `import.meta.url` is frozen to the build-machine path and `createRequire` throws during evaluation, so the whole route failed to load. `createRequire` is now resolved lazily inside the guarded `better-sqlite3` block, root-dir resolution falls back to `process.cwd()`, and the route passes an explicit `rootDir`. ([#5028](https://github.com/diegosouzapw/OmniRoute/pull/5028))
|
||||
|
||||
**Dashboard**
|
||||
|
||||
- **fix(dashboard):** show custom provider given-name instead of internal id across dashboard pages — cache, combo health, compression analytics, cost overview, health/autopilot, provider stats, route explainability, provider utilization, runtime. Adds shared `resolveProviderName` resolver and `useProviderNodeMap` hook. (#4603)
|
||||
- **fix(dashboard):** on OAuth providers (e.g. GLM Coding), "Test all models" with auto-hide-failed now switches the model list to the "visible" filter after the run, so just-hidden failed models actually disappear on-screen — parity with the passthrough-provider path (#3610). Previously they were hidden in the DB but stayed visible under the "All" filter, so it looked like nothing was hidden. (#4887)
|
||||
- **fix(dashboard):** restore the home-page provider topology card that was hidden by a default state change in #4596. ([#4963](https://github.com/diegosouzapw/OmniRoute/pull/4963))
|
||||
- **fix(dashboard):** proxy-pool success gating, sync-timestamp persistence, and opt-in Redis backend. ([#4988](https://github.com/diegosouzapw/OmniRoute/pull/4988))
|
||||
- **fix(dashboard):** show custom vision models in the LLM selector dropdown. ([#4653](https://github.com/diegosouzapw/OmniRoute/pull/4653))
|
||||
|
||||
**Providers**
|
||||
|
||||
- **fix(pollinations):** stop forcing `jsonMode` on every request. Pollinations treats `jsonMode=true` as "the model MUST return JSON" and rejects (HTTP 400 "messages must contain the word 'json'") any normal chat request whose messages don't mention "json", so all non-JSON chat was broken. `jsonMode` is now only enabled when the caller actually requests JSON output (`response_format.type` of `json_object` or `json_schema`). (#3981)
|
||||
- **fix(antigravity):** default `safetySettings` to all-OFF for parity with the native Gemini paths. The Antigravity (Google Cloud Code) request builder set `safetySettings: undefined`, which `JSON.stringify` drops — so no safety settings reached Google and its server-side defaults false-flagged benign technical prompts as `prohibited_content` (HTTP 200 + blocked body, which combo failover treats as terminal). Now honors a caller-supplied value and otherwise defaults to `DEFAULT_SAFETY_SETTINGS`, matching the claude-to-gemini / openai-to-gemini paths. (#5003)
|
||||
- **fix(antigravity):** exclude the standard Gemini rate-limit message from quota-exhaustion keyword matching to prevent false-positive saturation flags. ([#4810](https://github.com/diegosouzapw/OmniRoute/pull/4810) — thanks @Chewji9875)
|
||||
- **fix(chatgpt-web):** map the advertised `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4-pro` and `gpt-5.2-pro` catalog ids to their dash-form ChatGPT backend slugs. They were missing from `MODEL_MAP`, so the executor sent the dot-form id verbatim, which the ChatGPT backend silently ignored and served the default Plus model instead of the requested one. Adds a drift guard asserting no advertised dot-form id reaches the backend verbatim. (#4665)
|
||||
- **fix(gemini):** preserve the `pattern` field in the Antigravity tool schema sanitizer to avoid stripping valid regex constraints from tool definitions. ([#4651](https://github.com/diegosouzapw/OmniRoute/pull/4651))
|
||||
- **fix(opencode):** preserve DeepSeek reasoning content in streamed responses. ([#4631](https://github.com/diegosouzapw/OmniRoute/pull/4631))
|
||||
- **fix(perplexity):** validate API keys via the `/v1/models` endpoint instead of issuing a full chat request. ([#4654](https://github.com/diegosouzapw/OmniRoute/pull/4654))
|
||||
- **fix(qoder):** exchange PAT for `jt-*` job token before initiating Cosy chat, fixing auth failures after the Qoder credential format change. ([#4884](https://github.com/diegosouzapw/OmniRoute/pull/4884))
|
||||
- **fix(executors):** strip parameters unsupported by the target provider/model to prevent `400 Invalid parameter` errors on strict endpoints. ([#4658](https://github.com/diegosouzapw/OmniRoute/pull/4658))
|
||||
- **fix(executors):** preserve literal `reasoning_effort: "max"` for Ollama Cloud instead of normalizing to `xhigh`. Ollama Cloud accepts `high|medium|low|max|none` and rejects `xhigh` (`invalid reasoning value: 'xhigh'`); OpenRouter DeepSeek `max→xhigh` normalization is unaffected. ([#4993](https://github.com/diegosouzapw/OmniRoute/pull/4993) — thanks @Thinkscape)
|
||||
- **fix(headroom):** translate openai-responses input through OpenAI for external compression. `adaptBodyForCompression` now serialises `function_call_output` items whose `output` field is a JSON object (not a string) so compression engines can process the content — previously those items were excluded from compression because `hasTextContent()` returned false for object values. ([#5023](https://github.com/diegosouzapw/OmniRoute/pull/5023) — thanks @anki1kr)
|
||||
- **fix(proxy):** fan out direct dispatcher streams to all registered proxy endpoints. ([#4803](https://github.com/diegosouzapw/OmniRoute/pull/4803) — thanks @makcimbx)
|
||||
|
||||
**Compression**
|
||||
|
||||
- **fix(compression):** eliminate ReDoS in the `math_inline` preservation regex — the previous pattern could catastrophically backtrack on untrusted input. ([#4838](https://github.com/diegosouzapw/OmniRoute/pull/4838))
|
||||
- **fix(compression):** stop RTK over-truncating file-read tool results — RTK now respects the full content length for file-read outputs. ([#4987](https://github.com/diegosouzapw/OmniRoute/pull/4987))
|
||||
|
||||
**Build / CLI / infrastructure**
|
||||
|
||||
- **fix(build):** drop `@omniroute/open-sse` from `optimizePackageImports` to fix the Next.js build OOM crash. ([#4968](https://github.com/diegosouzapw/OmniRoute/pull/4968))
|
||||
- **fix(cli):** SIGKILL the systray child PID before closing the IPC channel to prevent macOS NSStatusItem orphan processes. ([#4732](https://github.com/diegosouzapw/OmniRoute/pull/4732))
|
||||
- **fix(cli):** bump `better-sqlite3` runtime pin to 12.10.1 for Node 26 compatibility. ([#4685](https://github.com/diegosouzapw/OmniRoute/pull/4685))
|
||||
- **fix(cli):** harden the systray2 tray runtime (port from 9router#1080). ([#4628](https://github.com/diegosouzapw/OmniRoute/pull/4628))
|
||||
- **fix(cli-tools):** tolerate JSONC (comments and trailing commas) in tool settings files. ([#4659](https://github.com/diegosouzapw/OmniRoute/pull/4659))
|
||||
- **fix(install):** make the `transformers` dependency optional so CUDA-host installs that lack Python bindings succeed. ([#4807](https://github.com/diegosouzapw/OmniRoute/pull/4807) — thanks @megamen32)
|
||||
- **fix(db):** correct storage tuning settings to prevent WAL runaway on high-write workloads. ([#4834](https://github.com/diegosouzapw/OmniRoute/pull/4834) — thanks @rdself)
|
||||
- **fix(image):** prevent compatible nodes from shadowing provider aliases in the image routing table. ([#4656](https://github.com/diegosouzapw/OmniRoute/pull/4656))
|
||||
|
||||
**Plugin**
|
||||
|
||||
- **fix(plugin):** opencode `auth.json` dual-key fallback for the auto-prefix migration. The config hook now looks up both the prefixed (`opencode-omniroute`) and bare (`omniroute`) keys, so users who authenticated before the `opencode-` prefix landed no longer need to re-auth. ([#5027](https://github.com/diegosouzapw/OmniRoute/pull/5027) — thanks @herjarsa)
|
||||
|
||||
---
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **fix(security):** block SSRF allowlist bypass via `x-relay-path` header manipulation on Deno/Vercel relays. ([#4899](https://github.com/diegosouzapw/OmniRoute/pull/4899))
|
||||
- **fix(security):** pin image-fetch DNS resolution to prevent SSRF DNS-rebinding attacks (GHSA-cmhj-wh2f-9cgx). ([#4634](https://github.com/diegosouzapw/OmniRoute/pull/4634))
|
||||
- **fix(security):** do not trust the loopback socket as local-only when the server is behind a reverse proxy, closing a potential auth bypass path. ([#4632](https://github.com/diegosouzapw/OmniRoute/pull/4632))
|
||||
- **fix(security):** validate the Kiro region parameter to prevent SSRF via crafted region strings (GHSA-6mwv-4mrm-5p3m). ([#4629](https://github.com/diegosouzapw/OmniRoute/pull/4629))
|
||||
- **fix(copilot):** replace `execSync` shell interpolation with `execFile` in the `runOmniRouteCli` tool to prevent command injection. The user-supplied command is now split into an argv array and passed to `execFile` (no shell), so shell metacharacters are treated as literal text; error output is routed through `sanitizeErrorMessage()`. ([#5024](https://github.com/diegosouzapw/OmniRoute/pull/5024) — thanks @hamsa0x7)
|
||||
|
||||
---
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
**God-file decomposition (continued, #3501)**
|
||||
|
||||
- **refactor(chatCore):** extracted 12 focused helpers from `chatCore.ts` covering the streaming pipeline (`assembleStreamingPipeline`), cache-store logic (`storeStreamingSemanticCacheResponse`, `storeSemanticCacheResponse`), response headers (`assembleStreamingResponseHeaders`, `buildNonStreamingResponseHeaders`), JSON→SSE bridge (`maybeConvertJsonBodyToSse`), guardrail context (`buildPostCallGuardrailContext`), usage buffer (`applyClientUsageBuffer`), plugin hook (`runPluginOnRequestHook`), analytics (`writeCompressionAnalytics`, `emitOutputStyleTelemetry`), and compression predicates/settings (`resolveCompressionSettings`, et al.). ([#4811](https://github.com/diegosouzapw/OmniRoute/pull/4811)–[#4837](https://github.com/diegosouzapw/OmniRoute/pull/4837))
|
||||
- **refactor(sse/db/api):** continued decomposition of `services/usage.ts` (extracted quota-core, scalar/format helpers, Antigravity/GLM/MiniMax usage families), `db/core.ts` (schema-column reconciliation, snake↔camel column-mapping), `db/apiKeys.ts` (row-parsers, model-permission matching), and `validation.ts` (URL/headers/transport leaf layer, web-cookie/Meta-AI validators, enterprise-cloud + probe, audio/speech/apikey, search/embedding/rerank, and OpenAI/Anthropic format validators). ([#4921](https://github.com/diegosouzapw/OmniRoute/pull/4921)–[#4956](https://github.com/diegosouzapw/OmniRoute/pull/4956))
|
||||
- **refactor(pricing/providers):** decomposed `pricing.ts` into shared tiers + partitioned `DEFAULT_PRICING` modules, and split the `providers.ts` catalog into semantic data modules organized by provider family. ([#4917](https://github.com/diegosouzapw/OmniRoute/pull/4917), [#4918](https://github.com/diegosouzapw/OmniRoute/pull/4918))
|
||||
- **refactor(open-sse):** extract `safeParseJSON` utility and dedup `tryParseJSON` call sites; extract and dedup the fallback `tool_call` ID generation helper. ([#4735](https://github.com/diegosouzapw/OmniRoute/pull/4735), [#4736](https://github.com/diegosouzapw/OmniRoute/pull/4736))
|
||||
|
||||
**Quality & CI**
|
||||
|
||||
- **chore(quality):** release base-red reconciliation + ratchet rebaselines — file-size, env-doc, and catalog baseline updates across multiple gates. ([#4630](https://github.com/diegosouzapw/OmniRoute/pull/4630), [#4879](https://github.com/diegosouzapw/OmniRoute/pull/4879), [#4886](https://github.com/diegosouzapw/OmniRoute/pull/4886), [#4915](https://github.com/diegosouzapw/OmniRoute/pull/4915), [#4961](https://github.com/diegosouzapw/OmniRoute/pull/4961), [#4973](https://github.com/diegosouzapw/OmniRoute/pull/4973))
|
||||
- **ci(quality):** shift heavy validation gates to the PR→release merge fast-path to accelerate the release cycle. ([#4857](https://github.com/diegosouzapw/OmniRoute/pull/4857))
|
||||
- **fix(ci):** include `coverage/lcov.info` in the coverage-report artifact so SonarQube can consume it. ([#4670](https://github.com/diegosouzapw/OmniRoute/pull/4670))
|
||||
- **fix(test):** validate Anthropic-compatible connections via `POST /v1/messages` for accurate connectivity testing. ([#4657](https://github.com/diegosouzapw/OmniRoute/pull/4657))
|
||||
|
||||
**Docs**
|
||||
|
||||
- **docs(resilience):** document Quota-Share Concurrency Control — `max_concurrent` enforcement, serialization behavior, and cooldown-wait semantics. ([#4980](https://github.com/diegosouzapw/OmniRoute/pull/4980))
|
||||
- **docs(perf):** add per-endpoint p50/p95/p99 latency and cost budgets reference. ([#4867](https://github.com/diegosouzapw/OmniRoute/pull/4867) — thanks @KooshaPari)
|
||||
- **docs(ops):** add canonical incident response runbook. ([#4868](https://github.com/diegosouzapw/OmniRoute/pull/4868) — thanks @KooshaPari)
|
||||
- **docs(ops):** document the release-green family — `green-prs`, `check:release-green`, `babysit`, and nightly gate workflows. ([#4679](https://github.com/diegosouzapw/OmniRoute/pull/4679))
|
||||
- **docs(agentbridge):** document Electron `NODE_EXTRA_CA_CERTS`, real model IDs, and identity caveat for agent bridge integrations. ([#4718](https://github.com/diegosouzapw/OmniRoute/pull/4718))
|
||||
- **docs:** clarify Kiro provides ~50 credits/month per account, not unlimited. ([#4690](https://github.com/diegosouzapw/OmniRoute/pull/4690))
|
||||
|
||||
**Misc**
|
||||
|
||||
- **chore(claude,codex):** bump pinned CLI identities — Claude `2.1.158 → 2.1.187`, Codex `0.132.0 → 0.142.0`. ([#4883](https://github.com/diegosouzapw/OmniRoute/pull/4883))
|
||||
- **chore(dashboard):** rename Qoder display label from "Qoder AI" to "Qoder". ([#4733](https://github.com/diegosouzapw/OmniRoute/pull/4733))
|
||||
|
||||
---
|
||||
|
||||
## [3.8.35] — 2026-06-23
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Adaptive context compression (Phase 4)**: a four-layer compression upgrade landed across stacked PRs — an **Output Styles** registry (`terse-prose` / `less-code` / `terse-cjk`) ([#4694](https://github.com/diegosouzapw/OmniRoute/pull/4694) — thanks @diegosouzapw), an opt-in **SLM `ultra` tier** (two-tier LLMLingua with heuristic fallback) ([#4707](https://github.com/diegosouzapw/OmniRoute/pull/4707) — thanks @diegosouzapw), a **context-budget adaptive dial** (reserve-output ladder + floor) ([#4716](https://github.com/diegosouzapw/OmniRoute/pull/4716) — thanks @diegosouzapw), and an **offline evaluation harness** (PII-gated corpus, self-test judge, gold-grader, real-pipeline runner behind a `ModelClient` seam) ([#4720](https://github.com/diegosouzapw/OmniRoute/pull/4720) — thanks @diegosouzapw). All four layers share a single `CompressionRunTelemetry` contract.
|
||||
- **Redoc-rendered API docs**: a consolidated OpenAPI spec now lives at `docs/openapi.yaml` and is served as interactive Redoc documentation at `/api/docs`. ([#4781](https://github.com/diegosouzapw/OmniRoute/pull/4781) — thanks @KooshaPari / @diegosouzapw)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM ([#4757](https://github.com/diegosouzapw/OmniRoute/pull/4757) — closes #4719, thanks @diegosouzapw).
|
||||
- **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` ([#4755](https://github.com/diegosouzapw/OmniRoute/pull/4755) — closes #4698, thanks @diegosouzapw).
|
||||
- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array ([#4756](https://github.com/diegosouzapw/OmniRoute/pull/4756) — closes #4712, thanks @diegosouzapw).
|
||||
- **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw).
|
||||
- **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw).
|
||||
- **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself).
|
||||
- **Storage SQLite tuning**: `Cache Size` is now a positive KiB setting (for example, `16384`) that applies to SQLite as `PRAGMA cache_size = -16384`; Page Size and Cache Size changes are applied to the live database instead of being persisted only in the settings table.
|
||||
- **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw).
|
||||
- **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw).
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **chatCore (#3501)**: continued the incremental decomposition of `executeProviderRequest` and the streaming/non-streaming hooks into pure leaf modules — top-level helpers + 6 pure leaves ([#4571](https://github.com/diegosouzapw/OmniRoute/pull/4571)), `resolveExecutorWithProxy` + `getExecutionCredentials` ([#4646](https://github.com/diegosouzapw/OmniRoute/pull/4646)), Claude message transforms ([#4708](https://github.com/diegosouzapw/OmniRoute/pull/4708)), `persistAttemptLogs` ([#4717](https://github.com/diegosouzapw/OmniRoute/pull/4717)), `stageTrace` + `compressionUsageReceipt` ([#4721](https://github.com/diegosouzapw/OmniRoute/pull/4721)), `prepareUpstreamBody` ([#4730](https://github.com/diegosouzapw/OmniRoute/pull/4730)), parse + non-streaming usage-stats ([#4762](https://github.com/diegosouzapw/OmniRoute/pull/4762)), `recordContextEditingTelemetryHook` ([#4779](https://github.com/diegosouzapw/OmniRoute/pull/4779)), `scheduleQuotaShareConsumption` ([#4780](https://github.com/diegosouzapw/OmniRoute/pull/4780)), `emitRequestGamificationEvent` ([#4776](https://github.com/diegosouzapw/OmniRoute/pull/4776)), `runPluginOnResponseHook` ([#4782](https://github.com/diegosouzapw/OmniRoute/pull/4782)), `scheduleStreamingQuotaShareConsumption` ([#4784](https://github.com/diegosouzapw/OmniRoute/pull/4784)), `recordCompressionCacheStats` ([#4792](https://github.com/diegosouzapw/OmniRoute/pull/4792)), `writeCavemanOutputAnalytics` ([#4794](https://github.com/diegosouzapw/OmniRoute/pull/4794)), `recordStreamingUsageStats` ([#4791](https://github.com/diegosouzapw/OmniRoute/pull/4791)), and `recordStreamingCost` ([#4790](https://github.com/diegosouzapw/OmniRoute/pull/4790)). (thanks @diegosouzapw)
|
||||
- **Quality**: expand `check:release-green` to reproduce the full release-PR gate set locally ([#4758](https://github.com/diegosouzapw/OmniRoute/pull/4758) — thanks @diegosouzapw).
|
||||
- **db**: re-export `compressionRunTelemetry` from `localDb` to satisfy the db-rules gate ([#4775](https://github.com/diegosouzapw/OmniRoute/pull/4775) — thanks @diegosouzapw).
|
||||
- **Security docs**: add a canonical STRIDE-based threat model ([#4783](https://github.com/diegosouzapw/OmniRoute/pull/4783) — thanks @KooshaPari).
|
||||
- **Tests**: add a smoke test for the home-client dashboard ([#4793](https://github.com/diegosouzapw/OmniRoute/pull/4793) — thanks @JxnLexn).
|
||||
- **Docs**: credit **ponytail** and **OmniCompress** in the README inspiring-projects list and restore the `check:env-doc-sync` release-green by exempting the harness-only `OMNIROUTE_EVAL_CREDENTIALS` var ([#4799](https://github.com/diegosouzapw/OmniRoute/pull/4799) — thanks @diegosouzapw); declare the Phase 4 compression layers in the README + GUIDE ([#4801](https://github.com/diegosouzapw/OmniRoute/pull/4801) — thanks @diegosouzapw).
|
||||
- **Quality**: trim `combo-config.test.ts` comments back under the file-size cap (follow-up to #4774) ([#4800](https://github.com/diegosouzapw/OmniRoute/pull/4800) — thanks @diegosouzapw).
|
||||
|
||||
---
|
||||
|
||||
## [3.8.34] — 2026-06-23
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(executors): Microsoft 365 Copilot pure framing + connection helpers** — adds the request/response framing and connection helpers to support `m365.cloud.microsoft/chat` for individual M365 plans. ([#4696](https://github.com/diegosouzapw/OmniRoute/pull/4696) — thanks @skyzea1 / @diegosouzapw)
|
||||
- **feat(compression): per-request `x-omniroute-compression` header (Phase 3)** — a request header now overrides the compression plan with the highest precedence (`request-header > routing > profile > auto-trigger > Default > off`), accepting `off` / `default` / `engine:<id>` / `<combo>`. The response echoes `X-OmniRoute-Compression: <mode>; source=<source>`. ([#4645](https://github.com/diegosouzapw/OmniRoute/pull/4645) — thanks @diegosouzapw)
|
||||
- **feat(audio): MiniMax T2A v2 TTS dispatch in `audioSpeech`** — adds MiniMax text-to-speech dispatch (port of upstream #1043). ([#4553](https://github.com/diegosouzapw/OmniRoute/pull/4553) — thanks @diegosouzapw)
|
||||
- **feat(opencode): OpenCode Go DeepSeek reasoning variants** — registers the Go DeepSeek reasoning model variants. ([#4647](https://github.com/diegosouzapw/OmniRoute/pull/4647) — thanks @DevEstacion)
|
||||
- **feat(quota): quota scraping for OpenCode Go and Ollama Cloud** — surfaces quota windows for the OpenCode Go and Ollama Cloud providers. ([#4642](https://github.com/diegosouzapw/OmniRoute/pull/4642) — thanks @JxnLexn)
|
||||
- **feat(settings): expose stream recovery feature flags** — surfaces the stream-recovery toggles in settings. ([#4586](https://github.com/diegosouzapw/OmniRoute/pull/4586) — thanks @rdself)
|
||||
- **feat(providers): optional model ID for custom API-key validation** — custom API-key connection tests can now specify the model ID used to validate the key. ([#4555](https://github.com/diegosouzapw/OmniRoute/pull/4555) — thanks @diegosouzapw)
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(tier): noAuth providers count as free; `auto/<cat>:free` returns an empty pool when no free candidate matches** — `freeProviders` is now the union of the legacy explicit list and every chat-tier `noAuth` provider derived from `NOAUTH_PROVIDERS` (so opencode / mimocode / duckduckgo-web are correctly classified free), the task-fitness lookup inherits a base model's `arena_elo` for its `-free` variant, and the `auto/<category>:<tier>` filter no longer silently falls back to the full pool — a `:free` request that matches no connected free model returns empty instead of billing a paid model (opt back into the legacy fallback with `OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true`). Corrupted/invalid `tier_config` rows now log a structured warning and fall back to defaults instead of throwing. ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753), [#4517](https://github.com/diegosouzapw/OmniRoute/issues/4517) — thanks @megamen32)
|
||||
- **fix(db): scheduled VACUUM follows Storage settings** — the SQLite VACUUM scheduler now uses the existing Storage page `scheduledVacuum` / `vacuumHour` configuration as its single source of truth, refreshes immediately when those settings are saved, and no longer exposes a separate environment-variable control path.
|
||||
- **fix(db): scheduled cleanup actually runs + queries target the real tables (DB-bloat / OOM)** — `runAutoCleanup` was never scheduled, so retention cleanup never executed and tables (`compression_analytics`, `usage_history`, …) grew unbounded into multi-GB SQLite files driving high RSS. Worse, several cleanup queries referenced wrong table/column names (`call_logs.created_at`→`timestamp`, `compression_analytics.created_at`→`timestamp`, `mcp_audit_log`→`mcp_tool_audit`, `a2a_events`→`a2a_task_events`, `memory_entries`→`memories`), so even a manual run silently no-op'd or errored. Fixed the five queries to match the real schema, added `cleanupProxyLogs`, and wired a `startCleanupScheduler` (startup + every 6h, VACUUM after deletes) into `server-init` alongside the existing budget-reset and reasoning-cache jobs. ([#4691](https://github.com/diegosouzapw/OmniRoute/pull/4691), extracted from [#4428](https://github.com/diegosouzapw/OmniRoute/pull/4428) — thanks @oyi77 / @diegosouzapw)
|
||||
- **fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template** — noAuth provider models are no longer skipped when building auto-combos, `reka-flash` is registered, and a `best-free` combo template is added. ([#4621](https://github.com/diegosouzapw/OmniRoute/pull/4621) — thanks @oyi77)
|
||||
- **fix: noAuth provider validation + Kimi executor routing** — corrects noAuth provider membership checks and removes a mis-routed Kimi alias. (closes #4620) ([#4699](https://github.com/diegosouzapw/OmniRoute/pull/4699) — thanks @oyi77)
|
||||
- **fix(executors): Firecrawl `web_fetch` 500 with `include_metadata=true`** — fixes a crash when Firecrawl web_fetch is invoked with metadata extraction enabled. ([#4692](https://github.com/diegosouzapw/OmniRoute/pull/4692) — thanks @ponkcore)
|
||||
- **fix(proxy): apply `pipelining:0` + connections cap to the direct dispatcher** — same-provider concurrent requests no longer serialize behind a long/streaming request on the direct path. ([#4684](https://github.com/diegosouzapw/OmniRoute/pull/4684) — thanks @jeffer1312 / @diegosouzapw)
|
||||
- **fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable** — stops repeatedly attempting to connect to `LIVE_WS_PORT` when live monitoring is not configured. ([#4687](https://github.com/diegosouzapw/OmniRoute/pull/4687) — thanks @FikFikk / @diegosouzapw)
|
||||
- **fix(api): serve `GET /v1/models/{model}` as JSON, not the HTML dashboard** — the per-model endpoint (IDs with slashes via a catch-all route) now returns JSON, unbreaking Claude Code. ([#4677](https://github.com/diegosouzapw/OmniRoute/pull/4677) — thanks @papajo / @diegosouzapw)
|
||||
- **fix(executors): robust deepseek-web tool-call parsing and agentic context retention** — hardens DeepSeek-web tool-call parsing and preserves agentic context across turns. ([#4644](https://github.com/diegosouzapw/OmniRoute/pull/4644) — thanks @BugsBag)
|
||||
- **fix(cli): authenticate `omniroute logs` and honor the active context** — the `logs` command now authenticates and respects the active context. ([#4638](https://github.com/diegosouzapw/OmniRoute/pull/4638) — thanks @Rahulsharma0810)
|
||||
- **fix(stream): estimate input tokens when upstream reports `prompt_tokens=0`** — input token usage is estimated when the upstream omits it. ([#4615](https://github.com/diegosouzapw/OmniRoute/pull/4615) — thanks @adivekar-utexas)
|
||||
- **fix(plugin): auto-prefix providerId with `opencode-` for OpenCode 1.17.8+ native gate** — adapts provider IDs to the OpenCode 1.17.8+ native provider gate. ([#4527](https://github.com/diegosouzapw/OmniRoute/pull/4527) — thanks @herjarsa)
|
||||
- **fix(catalog): shorten no-thinking gateway prefix to `no-think/`** — renames the no-thinking gateway prefix. ([#4525](https://github.com/diegosouzapw/OmniRoute/pull/4525) — thanks @Rahulsharma0810)
|
||||
- **fix(models): unknown max output limits no longer default to 8192** — models without synced/registry/static `maxOutputTokens` resolve the limit as unknown instead of a generic 8192 cap; clamping/injection only happens when a real cap is known. ([#4584](https://github.com/diegosouzapw/OmniRoute/pull/4584) — thanks @rdself)
|
||||
- **fix(resilience): respect upstream retry-hint toggle** — honors the configured toggle for upstream retry hints. ([#4585](https://github.com/diegosouzapw/OmniRoute/pull/4585) — thanks @rdself)
|
||||
- **fix(providers): show revealed connection API keys** — fixes revealing stored connection API keys in the UI. ([#4583](https://github.com/diegosouzapw/OmniRoute/pull/4583) — thanks @rdself)
|
||||
- **fix(logs): make active-request stale sweep configurable** — exposes the stale-request sweep interval as a setting. ([#4599](https://github.com/diegosouzapw/OmniRoute/pull/4599) — thanks @rdself)
|
||||
- **fix(resilience): retain provider cooldowns for the configured max window** — cooldowns persist for the configured maximum window. ([#4588](https://github.com/diegosouzapw/OmniRoute/pull/4588) — thanks @KooshaPari)
|
||||
- **fix(resilience): reject invalid provider cooldown bounds** — validates cooldown bound configuration. ([#4589](https://github.com/diegosouzapw/OmniRoute/pull/4589) — thanks @KooshaPari)
|
||||
- **fix(combo): preserve production combo metrics on shadow eviction** — shadow eviction no longer drops production combo metrics. ([#4590](https://github.com/diegosouzapw/OmniRoute/pull/4590) — thanks @KooshaPari)
|
||||
- **fix(combo): exclude exhausted connections from auto scoring** — exhausted connections are no longer scored as auto-combo candidates. ([#4592](https://github.com/diegosouzapw/OmniRoute/pull/4592) — thanks @KooshaPari)
|
||||
- **fix(relay): apply IP rate limit to the Bifrost sidecar** — extends IP rate limiting to the Bifrost relay sidecar. ([#4593](https://github.com/diegosouzapw/OmniRoute/pull/4593) — thanks @KooshaPari)
|
||||
- **fix(bifrost): finalize SSE relay usage after stream** — finalizes relay usage accounting once the SSE stream completes. ([#4612](https://github.com/diegosouzapw/OmniRoute/pull/4612) — thanks @KooshaPari)
|
||||
- **fix(quota): expose Bailian quota windows** — surfaces Bailian provider quota windows. ([#4610](https://github.com/diegosouzapw/OmniRoute/pull/4610) — thanks @KooshaPari)
|
||||
- **fix(dashboard): gate home topology live-WS networking behind widget visibility** — the home dashboard no longer starts topology polling / live sockets when topology is hidden. ([#4618](https://github.com/diegosouzapw/OmniRoute/pull/4618), [#4606](https://github.com/diegosouzapw/OmniRoute/pull/4606) — thanks @KooshaPari)
|
||||
- **fix(dashboard): isolate the quota widget refresh clock** — the quota widget refresh no longer drives unrelated re-renders. ([#4611](https://github.com/diegosouzapw/OmniRoute/pull/4611) — thanks @KooshaPari)
|
||||
- **fix(dashboard): memoize compatible provider groups** — avoids recomputing compatible provider groups on every render. ([#4613](https://github.com/diegosouzapw/OmniRoute/pull/4613) — thanks @KooshaPari)
|
||||
- **fix(cli): align `omniroute` data dir and env loading with the runtime** — the CLI's data-dir/env loading no longer drifts from the server runtime configuration. ([#4619](https://github.com/diegosouzapw/OmniRoute/pull/4619), [#4607](https://github.com/diegosouzapw/OmniRoute/pull/4607) — thanks @KooshaPari)
|
||||
- **fix(api/settings): prevent cached `/api/settings` responses** — disables caching on the settings endpoint (port from 9router#951). ([#4566](https://github.com/diegosouzapw/OmniRoute/pull/4566) — thanks @diegosouzapw)
|
||||
- **fix(executors): strip temperature for the GitHub Copilot gpt-5.4 family** — removes the unsupported `temperature` param for Copilot gpt-5.4 models (port from 9router#612). ([#4564](https://github.com/diegosouzapw/OmniRoute/pull/4564) — thanks @diegosouzapw)
|
||||
- **fix(dashboard): keep play_arrow spinning on provider "Test All" buttons** — fixes the spinner state on the provider test buttons (port from 9router#715). ([#4563](https://github.com/diegosouzapw/OmniRoute/pull/4563) — thanks @diegosouzapw)
|
||||
- **fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails** — shows a manual-config call-to-action on the Open Claw CLI card when auto-detection fails. ([#4562](https://github.com/diegosouzapw/OmniRoute/pull/4562) — thanks @diegosouzapw)
|
||||
- **fix(oauth): update Qwen OAuth URLs from `chat.qwen.ai` to `qwen.ai`** — refreshes the Qwen OAuth endpoints (port of decolua/9router#683). ([#4561](https://github.com/diegosouzapw/OmniRoute/pull/4561) — thanks @diegosouzapw)
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **refactor(imageGeneration): extract 8 provider families to co-located files** — splits the image-generation module into eight co-located per-provider files with no behavioral change. ([#4609](https://github.com/diegosouzapw/OmniRoute/pull/4609) — thanks @KooshaPari)
|
||||
- **deps: bump production + development groups; migrate js-yaml to v5 (ESM)** — dependency bumps plus a `js-yaml` v4→v5 migration to the ESM-only namespace import. ([#4697](https://github.com/diegosouzapw/OmniRoute/pull/4697) — thanks @diegosouzapw)
|
||||
- **chore(quality): release-green pre-flight validator + nightly signal** — new `npm run check:release-green` (`scripts/quality/validate-release-green.mjs`) reproduces the release-equivalent validation (full unit + vitest + ratchets + typecheck + lint, optional `--with-build` package-artifact) against the current working tree and classifies each red as **HARD** (real defect) vs **DRIFT** (ratchet, rebaselined at release) — purely diagnostic, never blocking contributors. A new `nightly-release-green` workflow runs it on the active release branch and opens/updates a tracking issue on hard failures. Closes the gap where the full gate (`ci.yml`) only ran on the release PR, so reds accrued silently on `release/**` and surfaced in layers at release time. ([#4622](https://github.com/diegosouzapw/OmniRoute/pull/4622) — thanks @diegosouzapw)
|
||||
- **chore(quality): reconcile file-size baseline for #4644 (`deepseek-web.ts` 1117→1125)** — rebaselines the file-size gate after the deepseek-web hardening. ([#4695](https://github.com/diegosouzapw/OmniRoute/pull/4695) — thanks @diegosouzapw)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.33] — 2026-06-21
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(combo): nested combo-ref execution (`nestedComboMode: execute`)** — selection strategies can now treat a combo-reference step as a black box, executing the referenced combo as a single unit instead of flattening its targets. ([#4537](https://github.com/diegosouzapw/OmniRoute/pull/4537) — thanks @adivekar-utexas)
|
||||
- **feat(combo): sticky weighted selection limit with exhaustion-aware renormalization** — weighted strategies gain a configurable sticky-selection limit; once a target is exhausted, remaining weights renormalize so traffic is redistributed correctly. ([#4489](https://github.com/diegosouzapw/OmniRoute/pull/4489) — thanks @adivekar-utexas)
|
||||
- **feat(combos): provider-wildcard expansion in combo steps** — a combo step may now reference a whole provider via wildcard and have it expand to that provider's models at resolution time. ([#4545](https://github.com/diegosouzapw/OmniRoute/pull/4545) — thanks @Rahulsharma0810)
|
||||
- **feat(compression): Phase 2 — named profiles + active selector** — the compression settings panel becomes the single source of truth via a single active-profile selector (Default panel vs a named combo) wired into the runtime. ([#4521](https://github.com/diegosouzapw/OmniRoute/pull/4521) — thanks @diegosouzapw)
|
||||
- **feat(sse): route `web_search` requests to a configured model** — CCR-style webSearch scenario: requests carrying a `web_search*` tool can be routed to a dedicated `webSearchRouteModel`, configurable from the Routing tab. ([#4509](https://github.com/diegosouzapw/OmniRoute/pull/4509) — thanks @shafqatevo / @diegosouzapw)
|
||||
- **feat(mcp): `omniroute_web_fetch` tool for URL content extraction** — new MCP tool that fetches and extracts the content of a URL. ([#4510](https://github.com/diegosouzapw/OmniRoute/pull/4510) — thanks @ponkcore)
|
||||
- **feat(models): qualify duplicate model names with their provider prefix** — when two providers expose a same-named model, the catalog now disambiguates each with its provider prefix. ([#4516](https://github.com/diegosouzapw/OmniRoute/pull/4516) — thanks @Rahulsharma0810)
|
||||
- **feat(translator): accept OpenAI audio input parts in Gemini translation** — `input_audio` message parts are now translated through to Gemini. ([#4434](https://github.com/diegosouzapw/OmniRoute/pull/4434) — thanks @diegosouzapw)
|
||||
- **feat(webhooks): enrich Telegram request notifications** — Telegram webhook payloads carry richer request context. ([#4524](https://github.com/diegosouzapw/OmniRoute/pull/4524) — thanks @mppata-glitch)
|
||||
- **feat(bazaarlink): add `authHint` to the existing APIKEY_PROVIDERS entry** — surfaces the auth hint for the bazaarlink provider. ([#4522](https://github.com/diegosouzapw/OmniRoute/pull/4522) — thanks @adivekar-utexas)
|
||||
- **feat(usage): API-key USD quota percent + reset hints, weekly-window cutoff** — usage dashboard surfaces API-key USD quota percentage and reset hints, honoring the weekly window cutoff. ([#4398](https://github.com/diegosouzapw/OmniRoute/pull/4398) — thanks @Witroch4)
|
||||
- **feat(usage): surface Codex code-review weekly window + `additional_rate_limits` fallback** — exposes the Codex code-review weekly window and falls back to `additional_rate_limits` when present. ([#4494](https://github.com/diegosouzapw/OmniRoute/pull/4494) — thanks @diegosouzapw)
|
||||
- **feat(dashboard): per-provider dropdown filter on the quota dashboard** — filter the quota dashboard by provider. ([#4495](https://github.com/diegosouzapw/OmniRoute/pull/4495) — thanks @diegosouzapw)
|
||||
- **feat(dashboard): inline show/hide toggle for API keys on the API Manager page** ([#4505](https://github.com/diegosouzapw/OmniRoute/pull/4505) — thanks @diegosouzapw)
|
||||
- **feat(dashboard): toggle-style model deselection in the combo builder modal** ([#4498](https://github.com/diegosouzapw/OmniRoute/pull/4498) — thanks @diegosouzapw)
|
||||
- **feat(dashboard): Done button in the model picker for combo creation** ([#4496](https://github.com/diegosouzapw/OmniRoute/pull/4496) — thanks @diegosouzapw)
|
||||
- **feat(providers): expose `gpt-4o` on the built-in GitHub Copilot (`gh`) provider** ([#4487](https://github.com/diegosouzapw/OmniRoute/pull/4487) — thanks @diegosouzapw)
|
||||
- **feat(pricing): default pricing for the Qwen coder-model on the `qw` provider** ([#4488](https://github.com/diegosouzapw/OmniRoute/pull/4488) — thanks @diegosouzapw)
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **fix(telemetry): back off the live-WS event bridge so a missing sidecar stops spamming ProxyFetch errors** — in single-port deployments the live-dashboard sidecar (port 20129) is not running, but `forwardDashboardEventToLiveWs` POSTed to it on every compression event. Since the global `fetch` is `proxyFetch`, each `ECONNREFUSED` logged a `[ProxyFetch] Undici dispatcher failed` warning (~272× in 42 min). The forwarder now backs off after consecutive failures (60s cooldown, lazy recovery) and clears the backoff on success, so a missing sidecar no longer floods the logs. ([#4604](https://github.com/diegosouzapw/OmniRoute/issues/4604) — thanks @FikFikk)
|
||||
- **fix(api): resolve a compatible provider node by base type, not only exact id** — connection→node resolution now matches on the bare derived node type when the exact id isn't found and the match is unambiguous (ambiguous → 404), via a pure `providerNodeSelect` helper. ([#4576](https://github.com/diegosouzapw/OmniRoute/pull/4576) — thanks @aleksesipenko / @diegosouzapw)
|
||||
- **fix(cli): supervisor restarts on spontaneous exit-0 (OOM cgroup) + waits for port before respawn** — a child that exits 0 because the cgroup OOM-killer reaped it is now restarted (not treated as a clean shutdown), the restart reset window widened 30s→60s, and the supervisor waits for the port to be free before respawning. ([#4578](https://github.com/diegosouzapw/OmniRoute/pull/4578) — thanks @oyi77 / @diegosouzapw)
|
||||
- **fix(combo): attribute lockout decay & success telemetry to the dynamically-selected connection** — on the combo success path the actual connection chosen by dynamic account-selection is read from the `X-OmniRoute-Selected-Connection-Id` response header (instead of the often-empty static `target.connectionId`), so model-lockout decay, `recordProviderSuccess`, LKGP and success/failure telemetry attribute to the right connection on both the priority and round-robin paths. The pre-screen "unavailable" snapshot is also no longer a permanent skip — availability is re-checked on each retry since connection cooldowns can expire mid-request. ([#4550](https://github.com/diegosouzapw/OmniRoute/pull/4550) — thanks @Chewji9875)
|
||||
- **fix(auto): enforce the quota cutoff before scoring (opt-in)** — auto-routing now evaluates a hard quota cutoff in `buildAutoCandidates` to drop low-quota candidates before scoring, with a 429 guard when all candidates fall below cutoff. The cutoff is **opt-in** behind `QuotaPreflightSettings.enabled` (default OFF via `QUOTA_PREFLIGHT_CUTOFF_ENABLED`), so default behavior is unchanged. ([#4483](https://github.com/diegosouzapw/OmniRoute/pull/4483) — thanks @megamen32)
|
||||
- **fix(antigravity): reasoning/thinking models no longer 400 with `oneOf at '/' not met`** — the Cloud Code envelope passthrough also leaked the Claude/OpenAI-native thinking fields (`thinking`, `reasoning_effort`, `reasoning`, `enable_thinking`, `thinking_budget`) the unified thinking adapter sets at the body root; Google rejected them with `400 Bad input: oneOf at '/' not met`. The whole thinking family is now stripped before the envelope is built; Gemini's own `generationConfig.thinkingConfig` is unaffected. ([#4485](https://github.com/diegosouzapw/OmniRoute/pull/4485) — port from 9router#1926, thanks @theseven99 / @diegosouzapw)
|
||||
- **fix(integration): restore the codex and memory pipeline contracts** — realigns the CLI fingerprint + memory-tools contracts so the codex and memory pipelines pass their integration checks again. ([#4474](https://github.com/diegosouzapw/OmniRoute/pull/4474) — thanks @KooshaPari)
|
||||
- **fix(sse): RTK must preserve `cache_control`-marked `tool_result` blocks** — reasoning-token-keeping no longer drops tool_result blocks that carry a `cache_control` marker. ([#4560](https://github.com/diegosouzapw/OmniRoute/pull/4560) — thanks @diegosouzapw)
|
||||
- **fix(auto-combo): respect model visibility (`isHidden`) in the auto-combo candidate pool** — hidden models are excluded from auto-combo candidates. ([#4558](https://github.com/diegosouzapw/OmniRoute/pull/4558) — thanks @herjarsa)
|
||||
- **fix(dashboard): avoid overlapping provider health polls** — guards against concurrent provider-health poll cycles overlapping. ([#4557](https://github.com/diegosouzapw/OmniRoute/pull/4557) — thanks @KooshaPari)
|
||||
- **fix(dashboard): make the API Manager key table usable on mobile** ([#4556](https://github.com/diegosouzapw/OmniRoute/pull/4556) — thanks @janeza2)
|
||||
- **fix(executors): decode Composer/Cursor `</think>`-marked visible output** — visible text wrapped in Cursor Composer's `</think>` markers is now decoded correctly. ([#4554](https://github.com/diegosouzapw/OmniRoute/pull/4554) — thanks @diegosouzapw)
|
||||
- **fix(oauth): improve Cursor auto-import reliability on macOS** ([#4552](https://github.com/diegosouzapw/OmniRoute/pull/4552) — thanks @diegosouzapw)
|
||||
- **fix(providers/test): probe the real Codex `/responses` endpoint** — connection test hits the actual Codex `/responses` endpoint. ([#4551](https://github.com/diegosouzapw/OmniRoute/pull/4551) — thanks @diegosouzapw)
|
||||
- **fix(mcp): `webFetchInput` emits `URL is required` for a missing url** — clearer validation error for the web-fetch tool. ([#4541](https://github.com/diegosouzapw/OmniRoute/pull/4541) — thanks @ponkcore / @diegosouzapw)
|
||||
- **fix(compression): allow `enginesExplicit` through the PUT validation schema** — the compression settings PUT no longer rejects the `enginesExplicit` flag. ([#4532](https://github.com/diegosouzapw/OmniRoute/pull/4532) — thanks @DevEstacion)
|
||||
- **fix(no-think): normalize provider prefix to canonical in no-think variants** ([#4531](https://github.com/diegosouzapw/OmniRoute/pull/4531) — thanks @Rahulsharma0810)
|
||||
- **fix(combo): pass `maxCooldownMs` from settings to the `recordModelLockoutFailure` call sites** ([#4530](https://github.com/diegosouzapw/OmniRoute/pull/4530) — thanks @Chewji9875)
|
||||
- **fix(combo): allow fallback on context-overflow & param-validation 400s; preserve upstream codes** — combo fallback now triggers on recoverable 400s while keeping the original upstream status. ([#4519](https://github.com/diegosouzapw/OmniRoute/pull/4519) — thanks @adivekar-utexas)
|
||||
- **fix(command-code): cap `max_tokens` per model using the registry `maxOutputTokens`** ([#4518](https://github.com/diegosouzapw/OmniRoute/pull/4518) — thanks @adivekar-utexas)
|
||||
- **fix(mitm): gate sudo prompts on server platform, not browser UA** ([#4514](https://github.com/diegosouzapw/OmniRoute/pull/4514) — thanks @diegosouzapw)
|
||||
- **fix(mitm): graceful sudo degradation in slim Docker / non-root containers** ([#4513](https://github.com/diegosouzapw/OmniRoute/pull/4513) — thanks @diegosouzapw)
|
||||
- **fix(usage): clear auth-expired message for Kiro social-auth accounts** ([#4512](https://github.com/diegosouzapw/OmniRoute/pull/4512) — thanks @diegosouzapw)
|
||||
- **fix(pricing): default cost rows for Antigravity Gemini 3.5 Flash tiers + `gemini-pro-agent`** ([#4508](https://github.com/diegosouzapw/OmniRoute/pull/4508) — thanks @diegosouzapw)
|
||||
- **fix(api): dedupe exact-duplicate ids in `/v1/models`** — low-noise model output without alias/canonical duplicates. ([#4506](https://github.com/diegosouzapw/OmniRoute/pull/4506) — thanks @Rahulsharma0810 / @diegosouzapw)
|
||||
- **fix(dashboard): enable Codex Apply/Reset buttons when the CLI is installed** ([#4504](https://github.com/diegosouzapw/OmniRoute/pull/4504) — thanks @diegosouzapw)
|
||||
- **fix(dashboard): show API-Key-compatible providers in the Antigravity CLI Tools model picker** ([#4503](https://github.com/diegosouzapw/OmniRoute/pull/4503) — thanks @diegosouzapw)
|
||||
- **fix(dashboard): migrate ManualConfigModal copy to the shared `useCopyToClipboard` hook** ([#4502](https://github.com/diegosouzapw/OmniRoute/pull/4502) — thanks @diegosouzapw)
|
||||
- **fix(sse): skip disabled providers in combo fallback** ([#4500](https://github.com/diegosouzapw/OmniRoute/pull/4500) — thanks @diegosouzapw)
|
||||
- **fix(usage): parse numeric-string quota reset timestamps as Unix sec/ms** ([#4493](https://github.com/diegosouzapw/OmniRoute/pull/4493) — thanks @diegosouzapw)
|
||||
- **fix(db): scheduled VACUUM + persist `lastVacuumAt`** — a new `vacuumScheduler.ts` persists the last run timestamp and last error to the `key_value` table (migration 102) and feeds the database settings panel; wired into the Next.js lifecycle (default 24h, window 02:00–04:00 local). The initial env-flag control path from this entry is superseded in v3.8.34 by the Storage page settings. ([#4480](https://github.com/diegosouzapw/OmniRoute/pull/4480) — thanks @KooshaPari / @oyi77)
|
||||
- **perf(quota): stop writing redundant `quota_snapshots` rows from idle connections** — the 60s background refresh persisted a snapshot for every window of every connection regardless of change, generating 400K+ rows/day from idle accounts. `setQuotaCache` now skips the write when a window's `remaining_percentage`/`is_exhausted` is unchanged from the last cached observation; the first observation and every real change still persist. ([#4565](https://github.com/diegosouzapw/OmniRoute/pull/4565), [#4438](https://github.com/diegosouzapw/OmniRoute/issues/4438) — thanks @oyi77)
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **fix(sse): crypto-secure RNG for combo/deck load-balancing selection** — replaces `Math.random()` with a crypto-secure source in the combo/deck weighted-selection path. ([#4455](https://github.com/diegosouzapw/OmniRoute/pull/4455) — thanks @diegosouzapw)
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **perf(dashboard): shrink provider assets + fix the usage rollup cutoff** — recompresses oversized provider images (nanobot/picoclaw/zeroclaw) and adds a `check:provider-assets` gate, plus a usage-analytics rollup cutoff fix. ([#4464](https://github.com/diegosouzapw/OmniRoute/pull/4464) — thanks @KooshaPari)
|
||||
- **refactor(chatCore): extract pure leaves from `chatCore.ts`** — incremental decomposition of the chat-core handler into pure, individually-testable leaves (system-role extraction, upstream-header build, failure usage-record builder, key-health, request-format, claude-effort, target-format, Background-Task-Redirect decision, Codex quota-state persistence). ([#4548](https://github.com/diegosouzapw/OmniRoute/pull/4548), [#4547](https://github.com/diegosouzapw/OmniRoute/pull/4547), [#4544](https://github.com/diegosouzapw/OmniRoute/pull/4544), [#4538](https://github.com/diegosouzapw/OmniRoute/pull/4538), [#4526](https://github.com/diegosouzapw/OmniRoute/pull/4526), [#4492](https://github.com/diegosouzapw/OmniRoute/pull/4492) — #3501, thanks @diegosouzapw)
|
||||
- **chore(i18n): remove unused config helpers** ([#4482](https://github.com/diegosouzapw/OmniRoute/pull/4482) — thanks @KooshaPari)
|
||||
- **chore(quality): reconcile quality baselines (complexity, cognitive-complexity, file-size) across the cycle** ([#4579](https://github.com/diegosouzapw/OmniRoute/pull/4579), [#4570](https://github.com/diegosouzapw/OmniRoute/pull/4570), [#4543](https://github.com/diegosouzapw/OmniRoute/pull/4543), [#4542](https://github.com/diegosouzapw/OmniRoute/pull/4542), [#4535](https://github.com/diegosouzapw/OmniRoute/pull/4535), [#4534](https://github.com/diegosouzapw/OmniRoute/pull/4534), [#4529](https://github.com/diegosouzapw/OmniRoute/pull/4529), [#4528](https://github.com/diegosouzapw/OmniRoute/pull/4528), [#4523](https://github.com/diegosouzapw/OmniRoute/pull/4523) — thanks @diegosouzapw)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.32] — 2026-06-20
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(dashboard): inline show/hide toggle for API keys on the API Manager page** — each row in the API keys list now exposes an eye / eye-off button next to the masked key. Clicking it lazy-fetches the full key via the existing `/api/keys/{id}/reveal` endpoint (so the policy gate is unchanged), caches it client-side, and renders the full value inline; clicking again hides it. The toggle only appears when `allowKeyReveal` is true (server policy), so an installation that disables reveal still sees a locked stub. Reuses the existing i18n keys `apiManager.showKey` / `apiManager.hideKey` already shipped in every locale, and clears the cached reveal when the key is deleted. Inspired-by: toanalien.
|
||||
- **feat(oauth): import accounts from CLIProxyAPI** — Settings → CLIProxyAPI now has an "Import accounts" button that reads the OAuth accounts CLIProxyAPI already saved in `~/.cli-proxy-api/` and imports them as OmniRoute connections, so you don't have to log into every account individually. CLIProxyAPI's unified auth-file format is parsed by `type` discriminator and the supported account types (Gemini, Codex, Claude/Anthropic, Antigravity, Qwen, Kimi) are upserted; unknown types are skipped. The preview never exposes tokens to the client. (thanks @powellnorma)
|
||||
- **feat(routing): opt-in setting to echo the requested alias/combo name in the response model field** — Settings → Routing now has an "Echo requested model name in responses" toggle (default off). When enabled, the response `model` field (non-streaming and every streamed SSE chunk) reports the alias or combo name the client requested instead of the upstream model name, so strict clients such as Claude Desktop — which reject a response whose `model` does not match the request with a 401 — work with aliases and combos. (thanks @thaiphuong1202)
|
||||
- **feat(providers): expand the openai and gemini direct registries with first-class variants already known elsewhere** — the `openai` provider entry now exposes `gpt-4.1-mini`, `gpt-4.1-nano`, `o3-mini`, and `o4-mini` (the latter two carry `REASONING_UNSUPPORTED` like `o3`), and the `gemini` entry now exposes `gemini-2.0-flash-lite` and `gemini-3-flash-lite-preview`. These models were already first-class throughout sibling subsystems (cost estimator, task fitness, free-model catalog, multiple aggregator registries) but happened to be missing from the direct openai/gemini namespaces. Embedding/TTS/image-gen models stay in their dedicated registries (`embeddingRegistry.ts`, `audioRegistry.ts`, `imageRegistry.ts`); legacy ids OmniRoute curated out (o1, gpt-4-turbo, …) are not restored. (thanks @East-rayyy)
|
||||
- **feat(translator): OpenAI SSE → Gemini SSE conversion for `/v1beta/models/{model}:streamGenerateContent`** — the `@google/genai` SDK (Gemini CLI) always calls `:streamGenerateContent?alt=sse` for chat and expects Gemini SSE chunks (no `[DONE]` sentinel — the stream just closes). The v1beta route was forwarding OpenAI SSE from `handleChat` unchanged, so the SDK crashed on the OpenAI `[DONE]` line with `SyntaxError: Unexpected token 'D', "[DONE]" is not valid JSON`. A new `transformOpenAISSEToGeminiSSE()` (in `open-sse/translator/response/openai-to-gemini-sse.ts`) rewrites each OpenAI delta into `candidates[].content.parts[]`, maps `finish_reason` → `finishReason` (STOP / MAX_TOKENS / SAFETY), attaches `usageMetadata` + `modelVersion` on the final chunk, and surfaces `reasoning_content` as `{ thought: true }` parts for thinking models. The non-streaming `:generateContent` action gets a sibling `convertOpenAIResponseToGemini()` for the JSON path. Streaming intent is now keyed off the URL action suffix (canonical Gemini convention) rather than the non-standard `generationConfig.stream` body field. (thanks @SteelMorgan)
|
||||
- **feat(compression): unified compression configuration panel (Phase 1)** — `/dashboard/context/settings` is now the single source of truth for compression: a master toggle plus per-engine on/off and level controls, with the dispatch pipeline derived from a stored `engines` map on `CompressionConfig`. A gate (`enginesExplicit`) ensures the new map only drives dispatch when an `engines` row was actually saved from the panel, so legacy/backfilled installs (the seeded default combo from migrations 042/043) keep their existing `defaultMode` behavior unchanged. The default-combo and per-engine routes are shimmed (410). ([#4432](https://github.com/diegosouzapw/OmniRoute/pull/4432) — thanks @diegosouzapw)
|
||||
- **feat(mcp): register the web-session pool observability tools** — the `poolTools` MCP tool set (web-session pool stats/health) was defined but never wired into `createMcpServer()`, so it was dead. It is now registered in `server.ts` with `withScopeEnforcement` against the typed `read:health` / `write:resilience` scopes (no enum inflation), giving MCP clients visibility into the pooled web-session lifecycle. ([#4399](https://github.com/diegosouzapw/OmniRoute/pull/4399), [#3368](https://github.com/diegosouzapw/OmniRoute/issues/3368) — thanks @diegosouzapw)
|
||||
- **feat(providers): stronger no-auth and web-cookie provider validation (`AUTH_007`)** — provider connection validation now handles no-auth and web-cookie providers explicitly: instead of returning a generic "Provider validation not supported", these providers report a precise `AUTH_007` status so the dashboard surfaces actionable validation feedback for cookie/no-auth flows. ([#4023](https://github.com/diegosouzapw/OmniRoute/pull/4023) — thanks @oyi77)
|
||||
- **feat(combo): per-combo `stickyRoundRobinLimit` override on the combos page** — the round-robin sticky-affinity limit can now be set per combo from the combos page UI, overriding the global default, so a combo can pin (or loosen) how many consecutive requests stick to the same round-robin member independently of the others. ([#4472](https://github.com/diegosouzapw/OmniRoute/pull/4472) — thanks @adivekar-utexas)
|
||||
- **feat(usage): quota fetch for `kimi-coding-apikey`** — usage/quota tracking now supports the `kimi-coding-apikey` provider, so its remaining quota is fetched and surfaced like the other quota-aware providers. ([#4435](https://github.com/diegosouzapw/OmniRoute/pull/4435) — thanks @janeza2)
|
||||
- **feat(cluster): opt-in memory + Bifrost cluster profiles** — adds opt-in cluster profiles that wire the memory subsystem and the Bifrost Go sidecar into a clustered deployment (follow-up to #3932). ([#4433](https://github.com/diegosouzapw/OmniRoute/pull/4433) — thanks @KooshaPari)
|
||||
- **feat(models): opt-in low-noise `/v1/models` catalog mode** — a new opt-in mode trims the `/v1/models` response to a quieter, lower-noise catalog for clients that choke on or don't need the full provider/model list. ([#4427](https://github.com/diegosouzapw/OmniRoute/pull/4427) — thanks @Rahulsharma0810)
|
||||
- **feat(ui): expose a `targetFormat` selector in the custom-models form** — the custom-models form now lets you pick the upstream target format explicitly, so a custom model can be pinned to the right wire format instead of relying on inference. ([#4475](https://github.com/diegosouzapw/OmniRoute/pull/4475) — thanks @adivekar-utexas)
|
||||
- **feat(providers): expose `gpt-4o` on the built-in GitHub Copilot (`gh`) provider** — GitHub Copilot still serves the original `gpt-4o` chat model via its `/chat/completions` endpoint, but the OmniRoute registry only shipped the GPT-5.x family, so clients that explicitly request `gpt-4o` against `gh` got an unknown-model error. `gpt-4o` is now registered under the `github` provider next to the GPT-5.x lineup (chat/completions, 128k context — no `openai-responses` targetFormat). Ported from [9router#98](https://github.com/decolua/9router/pull/98). (thanks @I3eka)
|
||||
- **feat(pricing): default pricing for Qwen `coder-model` on the `qw` provider** — the Qwen Coder Free (`qw`) registry already exposed the `coder-model` id (Qwen3.5/3.6 Coder Model) but `DEFAULT_PRICING.qw` was missing the row, so usage tracking reported `$0.00` for that model. The pricing row is now added with the same shape as the sibling `vision-model` tier, restoring non-zero cost tracking. Ported from upstream 9router PR [decolua/9router#156](https://github.com/decolua/9router/pull/156). (thanks @LinearSakana)
|
||||
- **feat(usage): Codex review-quota now surfaces the weekly window and the `additional_rate_limits` fallback shape** — the dashboard's Codex usage card showed only the **session** half of `code_review_rate_limit` and dropped review descriptors that arrived inside `additional_rate_limits` (the shape some ChatGPT Codex plans report). `buildCodexUsageQuotas` now emits the secondary window as `quotas.code_review_weekly` and, when the dedicated `code_review_rate_limit` block is empty, falls back to the matching descriptor in `additional_rate_limits` (matched on `limit_name`/`metered_feature`/`limit_id` containing `code_review` / `codex_review` / `review`). The new label `code_review_weekly → "Code Review Weekly"` is registered in `ProviderLimits/utils.tsx` so the card renders both windows side-by-side. The existing `quotas.code_review` key is preserved for back-compat. Inspired by upstream decolua/9router PR #836. (thanks @hiepau1231)
|
||||
- **feat(dashboard): per-provider dropdown filter on the quota dashboard** — the Quota dashboard now has a "Provider" dropdown alongside the existing Status / Type / Tier / Env filters. Choosing a provider narrows the visible accounts to that provider only; the selection persists in `localStorage` (`omniroute:limits:providerFilter`) and the dropdown auto-falls back to "All providers" if the persisted key no longer matches a connection in the current session. The dropdown only renders when there are at least two distinct providers in view, so single-provider setups aren't cluttered. The upstream "Expiring first" toggle is intentionally not ported — `visibleConnections` already always sorts by soonest reset within each status group, so the toggle would be redundant. Inspired by [decolua/9router#769](https://github.com/decolua/9router/pull/769) — thanks @DEYLNN.
|
||||
- **feat(dashboard): "Done" button in the model picker during combo creation** — `ModelSelectModal` now supports a `keepOpenOnSelect` prop (opt-in, off by default). When set — and the combos page now sets it — picking a model no longer auto-closes the modal, and a full-width "Done" button is rendered in the modal footer so users can add several models in a row and confirm explicitly. Single-select callers (e.g. CLI tool cards) are unchanged: the prop is opt-in, so they keep auto-close. The existing `multiSelect` mode (Clear + Done footer driven by `selectedModels`) takes precedence over `keepOpenOnSelect` to avoid two competing footers. Inspired by upstream PR [decolua/9router#1031](https://github.com/decolua/9router/pull/1031). (thanks @zanuartri)
|
||||
- **feat(dashboard): toggle-style model deselection inside the combo builder modal** — `ModelSelectModal` (used by the combo builder) now treats clicks on an already-added model as an inline remove instead of a duplicate add: the click invokes a new `onDeselect` callback when one is supplied, and a new `closeOnSelect={false}` prop keeps the modal open so several models can be added or removed in one session before the user closes it manually. Wired into the combo builder so the existing green "✓" highlight is now actionable — clicking it removes every step that points at that qualified model. Inspired-by upstream decolua/9router PR #889. (thanks @fajarhide)
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(sse): combo routing now skips a provider whose credentials are all disabled instead of failing the whole request** — when a combo like `antigravity/opus → github/opus` hit a leg whose only configured connections were disabled (or where no connections existed at all), `handleNoCredentials` returned `400 BAD_REQUEST`, which the combo target loop treats as a hard stop (combo's 400-break guard from PR #4316 / issue #4279 prevents infinite fallback loops on body-specific 4xx errors). The combo therefore died on the first leg even when later targets were perfectly healthy. The no-active-credentials branch now returns `404 NOT_FOUND` with `"No active credentials for provider: <p>"` instead — `404` flows through `checkFallbackError` as `shouldFallback: true` (generic-error catch-all path in `open-sse/services/accountFallback.ts`), so the next combo target is tried. The log level for this branch also drops from `error` to `warn` because zero active credentials is an expected operator-driven state, not a server fault. Inspired-by upstream decolua/9router PR #336. (thanks @East-rayyy)
|
||||
- **fix(dashboard): Manual Config modal "Copy" button now works on HTTP / non-secure deployments** — the copy handler in `ManualConfigModal` re-implemented the Clipboard-API-with-`execCommand`-fallback inline and gated the modern path on `window.isSecureContext`, so some non-secure-context browsers (and any future drift) silently lost the fallback. Migrated to the shared `useCopyToClipboard` hook (which delegates to `src/shared/utils/clipboard.ts`), giving consistent HTTP/HTTPS behavior with the rest of the dashboard and removing the duplicated code path. (thanks @anuragg-saxenaa)
|
||||
- **fix(dashboard): enable Codex Apply / Reset buttons when the CLI is installed** — on the Codex CLI tool card the **Apply** button was disabled whenever `selectedApiKey` was empty, but the local default `sk_omniroute` key is a valid choice when cloud mode is off or no API keys are configured — so Apply was stuck disabled even when the configuration was otherwise complete. **Reset** was also disabled when `codexStatus.hasOmniRoute` was false, which made it impossible to clear Codex configuration on installs that had never been pointed at OmniRoute. The disabled logic is now extracted into a pure helper (`codexButtonState.ts`) covered by unit tests: Apply is disabled only when no model is selected, or when cloud mode is on **and** keys exist **and** none is picked; Reset is disabled only while a reset is in flight. (thanks @anuragg-saxenaa)
|
||||
- **fix(mitm):** gate the sudo password prompt on the **server** platform, not the browser. The MITM control surface previously decided whether to ask for a sudo password by reading the browser's `navigator.userAgent`, which broke a Windows browser hitting a Linux server (no prompt → request rejected with `Missing sudoPassword`) and also forced an unnecessary modal on Linux hosts running as root, with NOPASSWD sudoers, or in minimal containers with no `sudo` binary on PATH. `GET /api/cli-tools/antigravity-mitm` now reports `isWin` and `needsSudoPassword` (probed via a safe `execFileSync("sudo", ["-n", "true"])`, per Hard Rule #13), and the Antigravity tool card uses the server-reported status to decide whether to show the modal. The POST/DELETE handlers stop returning 400 when sudo is genuinely not required. (thanks @hiepau1231)
|
||||
- **fix(embeddings):** forward output dimensions to Gemini for consistent embedding dims. (thanks @nguyenha935)
|
||||
- **fix(translator):** sanitize Read tool args from non-Anthropic models to prevent retry loops. (thanks @GodrezJr2)
|
||||
- **fix(usage):** reuse Gemini CLI project ID for quota checks (avoid re-discovery). (thanks @Delcado19)
|
||||
- **fix(dashboard):** surface manual config CTA when Claude CLI detection fails (remote deployments). (thanks @anuragg-saxenaa)
|
||||
- **fix(executors):** granular reasoning_effort handling for Claude models on GitHub Copilot. (thanks @baslr)
|
||||
- **fix(translator):** strip Claude output_config before MiniMax (rejected upstream). (thanks @hiepau1231)
|
||||
- **fix(translator): OpenAI audio input now reaches Gemini/Antigravity instead of being silently dropped** — `input_audio`/`audio` content parts on the OpenAI→Gemini path matched no handler in `convertOpenAIContentToParts` and were discarded with no error. They are now mapped to a Gemini `inlineData` part with an `audio/<format>` mime type (wav, mp3, …). (thanks @mugnimaestra)
|
||||
- **fix(combo): model lockout now honors a long upstream quota reset instead of retrying within minutes** — when a combo target returned a quota error carrying an explicit long reset (e.g. Antigravity `Resets in 160h27m24s`, a `Retry-After` header), the per-model lockout capped at the short base cooldown (~minutes) and discarded the parsed reset, so the exhausted model kept being retried far too early. The lockout now applies the parsed reset when it exceeds the base cooldown, and the Antigravity error-message parser also matches the plural `Resets in …` phrasing. (thanks @Ansh7473)
|
||||
- **fix(antigravity): Claude models no longer 400 with `Unknown name "output_config"`** — Anthropic/Claude-Code-only fields (`output_config`, legacy `output_format`) leaked into the Google Cloud Code request envelope via its top-level field passthrough, and Google rejects unknown envelope fields with `400 Invalid JSON payload received. Unknown name "output_config"` — breaking every Claude model served through Antigravity in IDEs. Those fields are now dropped before the envelope is built. (thanks @Duongkhanhtool)
|
||||
- **fix(combo): round-robin members fail over faster under concurrency saturation via a configurable queue depth** — when a round-robin combo member was saturated, requests sat in the per-model semaphore's **unbounded** queue and only failed over to the next member after the full `queueTimeoutMs` (default 30s) elapsed — so a burst of agentic requests deep-queued one hot member instead of spilling to healthy ones. The per-model semaphore now accepts a bounded queue depth and emits `SEMAPHORE_QUEUE_FULL` once it is full (the round-robin loop already cascades on that code), so a configured low depth fails over immediately. A new `queueDepth` combo-config knob (global default / provider override / per-combo, default **20** for backward compatibility; **0** = never queue → fail over now) is exposed in Settings → Combo Defaults. ([#3872](https://github.com/diegosouzapw/OmniRoute/issues/3872) — thanks @KooshaPari)
|
||||
- **fix(pricing): default cost rows for Antigravity's Gemini 3.5 Flash tiers + `gemini-pro-agent`** — the Antigravity public catalog (`ANTIGRAVITY_PUBLIC_MODELS`) ships `gemini-3-flash-agent`, `gemini-3.5-flash-low`, and `gemini-pro-agent` as user-callable client ids, but the `ag` block in the default pricing table only carried rows for `gemini-3-flash` / `gemini-3.1-pro-high`, so `getPricingForModel("ag", id)` returned `null` and cost / quota accounting silently fell back to `$0` for those three models. The missing rows are now seeded with the per-MTok rates the upstream quota tier bills at (Flash High/Medium share the legacy `gemini-3-flash` rate; `gemini-pro-agent` shares `gemini-3.1-pro-high`). (thanks @Ansh7473)
|
||||
- **fix(pricing): align Claude Code (`cc`) pricing with current Anthropic per-MTok rates** — the `cc` provider block in the default pricing table had stale numbers across every Claude 4.x family entry — most visibly, `claude-opus-4-5-20251101` was billed at the deprecated Opus 4.1 rate (`input $15` / `output $75`), and `claude-haiku-4-5-20251001` was at half the current Haiku 4.5 rate. The `cached` (cache hit) and `cache_creation` (5-minute cache write) multipliers were also off across Opus 4.6/4.7/4.8, Sonnet 4.5/4.6, Haiku 4.5, and Fable 5. All eight entries now match the rates Anthropic publishes (input, 5m cache write at 1.25x input, cache hit at 0.1x input, output; reasoning billed at the output rate), so cost accounting on the dashboard and per-request usage events stop under- or over-reporting Claude Code spend. (thanks @chulanpro5)
|
||||
- **fix(executors): sanitize Anthropic-shape content parts before GitHub Copilot `/chat/completions`** — Claude models on GitHub Copilot driven from clients like Cursor IDE (e.g. `gh/claude-sonnet-4.6`) failed with `Provider returned error: type has to be either 'image_url' or 'text' (reset after 30s)` because the client passed through Anthropic-shape content parts (`tool_use`, `tool_result`, `thinking`) untouched, and the Copilot chat-completions endpoint only accepts `text`/`image_url`. `GithubExecutor.transformRequest` now serializes any unsupported part type as `text` (preserving the model's context), drops empty parts, and collapses to `null` when an assistant message's only content was tool_calls — `tool_calls` ride alongside untouched. Codex-family models still route through `/responses` unchanged. (thanks @cngznNN)
|
||||
- **fix(sse):** refactor stall detection to reduce false positives on slow but progressing streams. (thanks @zakirkun)
|
||||
- **fix(executors): synthesize `x-opencode-request` for custom-named OpenCode providers** — the OpenCode CLI only emits the `x-opencode-*` header set when the provider id starts with `opencode`; a custom-named provider (e.g. `omniroute`) instead sends `x-session-affinity` / `x-session-id` (mapped to `x-opencode-session` since #4022) but no request-correlation id, so `x-opencode-request` was silently dropped. `OpencodeExecutor` now synthesizes a fresh `x-opencode-request` on that session-affinity fallback path so custom-named providers are not disadvantaged on the opencode.ai upstream. `x-opencode-client` / `x-opencode-project` are intentionally **not** fabricated (no valid client source — an invented value risks upstream rejection) and remain forward-only; `DefaultExecutor` is untouched. ([#4465](https://github.com/diegosouzapw/OmniRoute/issues/4465) — thanks @pizzav-xyz)
|
||||
- **fix(compression): RTK now compresses Anthropic-shape `tool_result` blocks** — `applyRtkCompression` only compressed OpenAI-shape tool results (`role:"tool"`); Anthropic-shape tool results (`tool_result` content blocks inside a `role:"user"` message) were skipped, so coding agents speaking the Anthropic Messages format got zero RTK savings even though RTK's command-aware filters (e.g. `git-status`) would have compressed the output. RTK now treats a message containing a `tool_result` block as eligible (gated by `applyToToolResults`), captures Anthropic `tool_use` blocks for command resolution, and compresses each block's inner text (string or nested text-block array) while preserving `type` + `tool_use_id` exactly — matching what `caveman`/`aggressive` already did. ([#4468](https://github.com/diegosouzapw/OmniRoute/pull/4468) — thanks @diegosouzapw)
|
||||
- **fix(dashboard): request-log auto-refresh no longer dies from a "ghost" load-more on first page load** — the request-log viewer's infinite-scroll `IntersectionObserver` uses a 200px rootMargin, so its sentinel was already intersecting on mount whenever the first page didn't fill the scroll container. That fired a `loadMore()` with no user interaction, growing the window past `PAGE_SIZE` — and auto-refresh only polls while on the first page (`limit <= pageSize`), so it stayed permanently paused (only a manual filter change re-armed it). The observer now grows the window only after a genuine user scroll (new pure `shouldTriggerInfiniteScroll` guard), and a filter change re-arms the guard, so the default first-page view resumes its ~10s auto-refresh. ([#4269](https://github.com/diegosouzapw/OmniRoute/issues/4269) — thanks @tjengbudi)
|
||||
- **fix(sse): large `/v1/chat/completions` requests no longer crash the server with a Node heap OOM** — the chat request body was parsed multiple times along the route (route guard, injection guard, handler), buffering very large payloads several times and pushing concurrent agentic traffic into an out-of-memory crash. The body is now parsed **once** at the route guard and threaded through, so each request is buffered a single time. ([#4380](https://github.com/diegosouzapw/OmniRoute/issues/4380) — thanks @NakHalal)
|
||||
- **fix(guardrails): tighten the `system_prompt_leak` heuristic to stop false positives on agent traffic** — the leak detector flagged normal agent/tool conversations as prompt-leak attempts; it now requires an additional qualifier before flagging, so legitimate agent traffic is no longer blocked. ([#4041](https://github.com/diegosouzapw/OmniRoute/issues/4041) — thanks @KooshaPari)
|
||||
- **fix(translator): drop orphan tool results on the Claude→OpenAI request path** — a `tool_result` with no preceding matching `tool_use` (orphan) produced upstream 500/502 errors for Command Code / Custom OpenAI clients on ≥3.8.26. Orphan tool results are now filtered before the request is sent. ([#4385](https://github.com/diegosouzapw/OmniRoute/issues/4385) — thanks @adityapnusantara)
|
||||
- **fix(providers): register API-key validators for Firecrawl and Jina Reader** — both providers returned "Provider validation not supported" when validating their API key; they now have proper validators registered in `SEARCH_VALIDATOR_CONFIGS`. ([#4401](https://github.com/diegosouzapw/OmniRoute/issues/4401) — thanks @ponkcore)
|
||||
- **fix(providers): generic web-cookie validator must not shadow per-provider validators** — a follow-up to the `AUTH_007` validation work (#4023): the generic web-cookie validator was matching before more specific per-provider validators, so provider-specific validation was skipped. Validator resolution now prefers the per-provider validator. ([#4467](https://github.com/diegosouzapw/OmniRoute/pull/4467) — thanks @diegosouzapw)
|
||||
- **fix(translator): inject a placeholder message when the Responses API `input[]` is empty** — a `POST /v1/responses` with `input: []` translated to `messages: []`, which every upstream Chat-Completions provider rejects (surfaced as a confusing 406); a single placeholder user message is now injected, mirroring the existing empty-string handling. ([#4393](https://github.com/diegosouzapw/OmniRoute/pull/4393) — thanks @diegosouzapw)
|
||||
- **fix(providers): serve the api.airforce live `/models` catalog instead of the stale seed** — the api.airforce provider listed a stale hard-coded seed; it now serves the upstream live `/models` catalog. ([#4395](https://github.com/diegosouzapw/OmniRoute/pull/4395) — thanks @diegosouzapw)
|
||||
- **fix(cli): non-interactive-safe prompts + `context` alias** — the CLI's `confirm()`/prompt helpers no longer hang in non-interactive (piped/CI) contexts, and a singular `context` alias is accepted alongside `contexts`; the contexts workflow is documented. ([#4439](https://github.com/diegosouzapw/OmniRoute/pull/4439), [#4397](https://github.com/diegosouzapw/OmniRoute/pull/4397) — thanks @diegosouzapw)
|
||||
- **fix(cli): `omniroute update` no longer reports a stale "latest" version from npm's cache** — `getLatestVersion()` ran `npm view omniroute version` without `--prefer-online`, so npm could serve a cached value from its HTTP cache and tell users on an older build (e.g. 3.8.30) they were already "running the latest version" even after a newer one (3.8.31) was published. The version check now passes `--prefer-online` to force npm to revalidate against the registry. ([#4376](https://github.com/diegosouzapw/OmniRoute/issues/4376) — thanks @akbardwi)
|
||||
- **fix(sse): `web_search_20250305` no longer 400s on MiniMax's Anthropic-compatible endpoint** — PR #2960 added a Claude→Claude bypass that forwards Anthropic's typed server tool `web_search_20250305` untouched, assuming the Claude-format upstream implements Anthropic server tools. MiniMax's `/anthropic` endpoint does not, so `claude → minimax` requests carrying that tool got `HTTP 400 "invalid params, function name or parameters is empty (2013)"`. `supportsNativeWebSearchFallbackBypass` now consults the (already-plumbed) `provider` and excludes providers known not to implement server tools (currently `minimax`) from the bypass, so the built-in web-search tool is converted to the `omniroute_web_search` function fallback — which MiniMax accepts as a normal function tool. ([#4481](https://github.com/diegosouzapw/OmniRoute/issues/4481) — thanks @shafqatevo)
|
||||
- **fix(command-code): pass `reasoning` / `thinking` fields through to upstream params** — Command Code requests carrying `reasoning`/`thinking` controls had those fields dropped before the upstream call, so reasoning-effort and extended-thinking settings were silently ignored; they are now forwarded to the upstream params. ([#4473](https://github.com/diegosouzapw/OmniRoute/pull/4473) — thanks @adivekar-utexas)
|
||||
- **fix(usage): keep Kiro overage-enabled accounts routable after base quota hits zero** — a Kiro account with overage enabled was excluded from routing once its base quota reached zero, even though overage billing should keep it serving; such accounts now stay routable past base-quota exhaustion. ([#4469](https://github.com/diegosouzapw/OmniRoute/issues/4469) — thanks @heaven321357 / @CleanDev-Fix)
|
||||
- **fix(providers): model-aware `supportsRedactedThinking` for mixed-format providers** — the redacted-thinking capability was resolved per provider rather than per model, so a mixed-format provider (some models support redacted thinking, others don't) got the wrong answer for some models; the check is now model-aware. ([#4479](https://github.com/diegosouzapw/OmniRoute/pull/4479) — thanks @TF0rd)
|
||||
- **fix(usage): parse numeric-string quota reset timestamps as Unix seconds/ms** — when a provider returned the quota reset timestamp as a numeric string (e.g. `"1700000000"`), `parseResetTime` passed it straight to `new Date(str)`, which returned `Invalid Date` and dropped the reset entirely (UI showed no reset). Numeric strings are now detected and treated as Unix timestamps with the same `< 1e12` seconds-vs-ms heuristic already applied to numeric values; ISO/parseable strings are untouched. Applied symmetrically in `codexUsageQuotas.parseResetTime`. (Inspired by upstream [decolua/9router#768](https://github.com/decolua/9router/pull/768) — thanks @DEYLNN)
|
||||
- **fix(usage): clearer "auth expired" message for Kiro accounts added via Google/GitHub social-auth** — a Kiro account created through the `/api/oauth/kiro/social-exchange` flow (Google or GitHub social login) uses a token format that AWS CodeWhisperer's `GetUsageLimits` quota API frequently rejects with 401/403 even when `/messages` still works. The quota card was throwing the raw upstream error blob (`Failed to fetch Kiro usage: Kiro API error (401): {…}`); social-auth accounts now get the same friendly `Kiro quota API authentication expired. Chat may still work.` message that legacy social-auth users with a stored marker already see, while Builder-ID / IDC accounts keep the existing throw-on-failure behavior so transient upstream errors don't get silently masked. (thanks @anuragg-saxenaa)
|
||||
- **fix(dashboard): Antigravity CLI Tools model picker now lists API-Key-Compatible custom providers** — the API-Key-compatible / passthrough provider groups in `ModelSelectModal` are derived from the user's `modelAliases`, but `AntigravityToolCard` was the only CLI tool card that didn't fetch `/api/models/alias` or forward the `modelAliases` prop, so a custom OpenAI-compatible provider added in OmniRoute never surfaced in the Antigravity tool's model picker — routing a custom model to Antigravity from there was impossible. The card now mirrors the pattern already used by every sibling tool card (Codex, Claude, Cline, Kilo, Droid, OpenClaw, HermesAgent). (thanks @mxskeen)
|
||||
- **fix(mitm): cert/DNS operations no longer fail with `spawn sudo ENOENT` on slim Docker images** — slim Docker base images (e.g. `node:24-trixie-slim`) do not ship `sudo`, and OmniRoute's runtime stage runs as `USER node` (UID 1000, non-root), so `execFileWithPassword("sudo", …)` failed unconditionally for any MITM operation triggered from inside the container (cert install, DNS host-file write). A new `isSudoAvailable()` probe gates the `sudo -S` wrapper; when sudo is missing and the process is not root, the underlying command runs directly (same user, no elevation) — same path already taken when running as root. Privileged operations that genuinely need elevation (system trust store, `/etc/hosts`) still error explicitly so operators can mount the CA or hosts file from the host side. (thanks @lokinh)
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **fix(sse): use a crypto-secure RNG for combo/deck load-balancing selection** — random combo/deck member selection used a non-cryptographic PRNG, flagged by CodeQL (`#665`); it now uses a crypto-secure RNG. ([#4457](https://github.com/diegosouzapw/OmniRoute/pull/4457) — thanks @diegosouzapw)
|
||||
- **fix(sse): unbiased `crypto.randomInt` for combo selection (follow-up to #4457)** — the initial crypto-secure conversion used modulo reduction over the secure bytes, which introduces a small modulo bias; selection now uses `crypto.randomInt` (rejection sampling) for a uniform, unbiased distribution across combo/deck members. ([#4462](https://github.com/diegosouzapw/OmniRoute/pull/4462) — thanks @diegosouzapw)
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **refactor(chatCore):** extract `resolveChatCoreRequestSetup` (first setup-phase slice) toward modularizing the chatCore god-file. ([#4392](https://github.com/diegosouzapw/OmniRoute/pull/4392) — thanks @diegosouzapw)
|
||||
- **refactor(chatCore):** extract the Codex service-tier resolvers into a pure `chatCore/serviceTier.ts` leaf (continues the god-file split). ([#4477](https://github.com/diegosouzapw/OmniRoute/pull/4477), [#3501](https://github.com/diegosouzapw/OmniRoute/issues/3501) — thanks @diegosouzapw)
|
||||
- **perf(dashboard):** lazy-load the usage analytics charts so the dashboard's initial bundle/paint is lighter (charts hydrate on demand). ([#4466](https://github.com/diegosouzapw/OmniRoute/pull/4466) — thanks @KooshaPari)
|
||||
- **perf(kiro):** cut request-completion hot-path CPU and cap the DB-lock event-loop block so Kiro request completion does not stall the event loop under load. ([#4459](https://github.com/diegosouzapw/OmniRoute/pull/4459) — thanks @artickc)
|
||||
- **fix(catalog):** restore-green — add OpenAI `gpt-4.1-mini`/`gpt-4.1-nano` + `o3-mini`/`o4-mini` pricing rows to keep the static-parity gate green after the registry expansion (#4394), plus the web-cookie validator shadowing fix. ([#4447](https://github.com/diegosouzapw/OmniRoute/pull/4447) — thanks @diegosouzapw)
|
||||
- **chore(quality):** reconcile file-size + complexity baselines after the `/review-prs` round, and the `server.ts` file-size baseline after the pool-tools registration (#3368). ([#4461](https://github.com/diegosouzapw/OmniRoute/pull/4461), [#4423](https://github.com/diegosouzapw/OmniRoute/pull/4423) — thanks @diegosouzapw)
|
||||
- **docs(remote-mode):** add a copy-paste end-to-end verification example. ([#4430](https://github.com/diegosouzapw/OmniRoute/pull/4430) — thanks @diegosouzapw)
|
||||
- **docs:** add operational documentation (usage/quota, database, open-sse architecture, monitoring). ([#3455](https://github.com/diegosouzapw/OmniRoute/pull/3455) — thanks @oyi77)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.31] — 2026-06-20
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(translator):** Gemini accepts OpenAI `input_audio` and `audio_url` content parts. (thanks @mugnimaestra)
|
||||
- **perf(dashboard): combos UI leaf-split, Next.js config tuning, 1-click Redis & Bifrost sidecar** — delivers four of the five performance/UX tracks from the #3932 thread: the combos dashboard page is split into focused leaf components (smaller bundles, faster reloads), `next.config` is tuned for the standalone build, Redis can be provisioned in one click, and a Bifrost sidecar option is wired in. (The fifth track — chatLogHelpers extraction — was already covered upstream and dropped.) ([#4381](https://github.com/diegosouzapw/OmniRoute/pull/4381) — thanks @KooshaPari)
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(embeddings): NVIDIA NIM asymmetric embedding models inject the required `input_type`** — NVIDIA NIM asymmetric embedders (e.g. `nvidia/nv-embedqa-e5-v5`) reject requests without an `input_type` parameter with `400 "'input_type' parameter is required"`, but OmniRoute only forwarded `input_type` when the client supplied it — so callers (and OpenAI-style SDKs that don't emit the field) got a hard failure. The embedding registry now carries a model-level default (`input_type: "query"`) for the asymmetric NVIDIA model, and the embeddings handler injects a model's default params into the upstream body **only** when the client didn't already send them — a client-supplied `input_type` (e.g. `"passage"`) is respected unchanged, and symmetric models that carry no default are unaffected. ([#4341](https://github.com/diegosouzapw/OmniRoute/pull/4341) — thanks @hydraromania)
|
||||
- **fix(api): migrate the deprecated Codex `[features].codex_hooks` flag to `[features].hooks`** — Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI versions ignore the old key and print a deprecation notice. When OmniRoute rewrites an existing `~/.codex/config.toml` (configuring/resetting the Codex provider) it now carries the user's intent forward by renaming `[features].codex_hooks` → `[features].hooks` (preserving its value, never clobbering an already-present `hooks`) and dropping the deprecated key. No-op when the flag is absent. ([#4342](https://github.com/diegosouzapw/OmniRoute/pull/4342) — thanks @Bian-Sh)
|
||||
- **fix(translator): same-format response path no longer leaks a `data: null` SSE event** — the streaming response translator's same-format fast path returned `[chunk]` unconditionally, so the end-of-stream null/flush signal (`chunk === null`) propagated as a literal `[null]`. Downstream this surfaced as an empty `data: null` SSE event between chunks and crashed strict clients (e.g. Factory Droid BYOK on `/v1/responses`). The fast path now drops the null flush (returns `[]`) while still passing real chunks through unchanged. ([#4344](https://github.com/diegosouzapw/OmniRoute/pull/4344) — thanks @thaitryhand)
|
||||
- **fix(translator): strip client-only assistant echo fields on the OpenAI target path (Mistral 422)** — strict OpenAI-compatible upstreams (e.g. `mistral/codestral-latest`) reject client-only assistant "echo" fields sent back as input history with `422 extra_forbidden` (the report hit `messages[].assistant.reasoning_content` via Codex `/responses`). Only `reasoning_content` was being stripped on the OpenAI target path; the sibling echo fields `reasoning`, `refusal`, `annotations` and `cache_control` leaked through and tripped the 422. They are now all dropped on the non-reasoner OpenAI target path. `audio` is deliberately preserved (OpenAI audio models reference a prior assistant audio response by id on multi-turn; Mistral never emits audio, so nothing is lost there). ([#4350](https://github.com/diegosouzapw/OmniRoute/pull/4350) — thanks @xxy9468615)
|
||||
- **fix(translator): accept AI SDK-style `{ type: "image", image: "data:…" }` content parts** — several OpenAI-input translators only recognized images shaped as `image_url.url` (or an object with `.source`/`.url`), so an AI SDK-style part where `image` is a bare data-URL **string** was silently dropped before reaching a vision provider (OpenCode is one affected client; the gap is generic). The OpenAI→Claude, OpenAI→Kiro and OpenAI→Gemini/Antigravity translators now parse a string `image` data URL into each provider's native image shape (Claude `{source:{type:"base64"}}`, Kiro `images[].source.bytes`, Gemini `inlineData`). ([#4345](https://github.com/diegosouzapw/OmniRoute/pull/4345) — thanks @mugnimaestra)
|
||||
- **fix(translator): Gemini accepts HTTP/HTTPS image URLs instead of silently dropping them** — the OpenAI→Gemini request helper (`convertOpenAIContentToParts`) discarded remote `image_url` parts (emitting only a `console.warn`) because Gemini's `inlineData` needs base64 and the synchronous helper can't fetch+encode upstream. It now uses Gemini's native `fileData: { fileUri }` part for HTTP/HTTPS URLs (the model fetches the asset itself), so vision requests carrying a URL — not a `data:` URI — reach Gemini intact. ([#4373](https://github.com/diegosouzapw/OmniRoute/pull/4373) — ported from 9router#344, thanks @diegosouzapw)
|
||||
- **fix(executors): strip `stream_options` for qwen non-streaming / thinking Claude-Code requests** — Claude-Code-compatible providers force the executor-level `stream` flag on while the outgoing body keeps the caller's original `stream: false`, so `DefaultExecutor.transformRequest` injected `stream_options: { include_usage: true }` onto a body that still said `stream: false`, and qwen rejected it with `400 "'stream_options' only set this when you set stream: true"`. The executor now strips `stream_options` whenever the body's effective `stream` is false. ([#4374](https://github.com/diegosouzapw/OmniRoute/pull/4374) — ported from 9router#663, thanks @anuragg-saxenaa / @diegosouzapw)
|
||||
- **fix(executors): don't inject `thinking` when `tool_choice` forces a tool (native Claude)** — the Claude-Code wire-image emulation injects `thinking: { type: "adaptive" }` for non-Haiku Claude models, but Anthropic rejects `thinking` when `tool_choice` forces a specific tool (`{type:"any"|"tool"}`) with `400 "Thinking may not be enabled when tool_choice forces tool use."`. Any Opus/Sonnet call that pins a tool (e.g. Claude Code's `message_user`, or agent harnesses that force a tool) hit a hard 400; the injection is now suppressed when `tool_choice` forces a tool. ([#4389](https://github.com/diegosouzapw/OmniRoute/pull/4389) — thanks @NomenAK)
|
||||
- **fix(codex): request reasoning summaries on Codex Responses requests** — Codex/OpenAI Responses can return reasoning-token accounting and empty reasoning items unless visible reasoning summaries are requested, so Codex CLI / pi.dev paths missed visible thinking text. OmniRoute now requests `reasoning.summary: "auto"` (and includes `reasoning.encrypted_content`) when reasoning is enabled — preserving an explicit client `reasoning.summary` and existing `include` entries, and skipping it for `reasoning.effort: "none"`. ([#4359](https://github.com/diegosouzapw/OmniRoute/pull/4359) — thanks @xz-dev)
|
||||
- **fix(sse): default the combo per-target timeout to 120s for fast failover** — a combo's per-target timeout inherited the full `FETCH_TIMEOUT_MS` (600s default) when the combo didn't set `targetTimeoutMs`, so a single hung/slow target (e.g. an openai-compatible upstream returning 524/504) could stall the **whole** combo for up to 10 minutes before failing over. A new `DEFAULT_COMBO_TARGET_TIMEOUT_MS = 120_000` is used as the default-when-unset in `resolveComboTargetTimeoutMs` (backward-compatible 3rd arg, wired in `phaseComboSetup`); an explicit ceiling/opt-out is preserved. ([#4365](https://github.com/diegosouzapw/OmniRoute/pull/4365) — thanks @diegosouzapw)
|
||||
- **fix(cli): Tailscale login honors `TAILSCALE_AUTHKEY` for non-interactive sign-in** — `startTailscaleLogin` built `tailscale up` without ever reading `process.env.TAILSCALE_AUTHKEY`, so on a pre-authenticated / headless daemon the login waited for an interactive auth URL and timed out (~15s). When `TAILSCALE_AUTHKEY` is set it is now passed via `--auth-key=` (as a spawn argv element — no shell interpolation) so the daemon authenticates non-interactively; when unset, behavior is unchanged. ([#4343](https://github.com/diegosouzapw/OmniRoute/pull/4343) — thanks @ipeterpetrus)
|
||||
- **fix(dashboard): OAuth modal shows the real error on a non-JSON server response** — the OAuth connect/reauth modal called `await res.json()` unconditionally, so when a build/OAuth endpoint returned a plain-text error (e.g. a `500 Internal Server Error` page) the modal threw `Unexpected token 'I'…` and hid the real failure. Two shared helpers (`parseResponseBody` / `getErrorMessage` in `src/shared/utils/api.ts`) now read the body safely (JSON when it is JSON, raw text otherwise) and surface a clean message either way; all modal fetch sites use them. ([#4351](https://github.com/diegosouzapw/OmniRoute/pull/4351) — thanks @DNNYF)
|
||||
- **fix(dashboard): a disabled connection's last error is now visible** — the provider card's error badge counts a disabled connection (`isActive === false`) that has an error (its effective status is still error/expired/unavailable), but the connection row hid the `lastError` text for disabled rows — so the operator saw the error count without being able to see what failed. The row now shows the error text whenever there is one, regardless of the active toggle. ([#4352](https://github.com/diegosouzapw/OmniRoute/pull/4352) — thanks @ntdung6868)
|
||||
- **fix(providers): the "Test Connection One-by-One" OAuth probe can no longer hang the queue forever** — the OAuth connection-test path called bare `fetch(url, { method, headers })` with no `AbortController`/signal/timeout, so when a provider's probe endpoint accepted the socket but never responded, the awaited fetch never settled and the one-by-one test queue stalled indefinitely (the API-key path was already bounded via `validateProviderApiKey`'s `timeoutMs`). Both the initial probe and the post-refresh retry are now bounded with `AbortSignal.timeout(30s)` — matching the API-key path's 30s budget — and a timed-out probe resolves as a failure with a clear `Test timed out after 30s` message in the same shape as every other test error. ([#4347](https://github.com/diegosouzapw/OmniRoute/pull/4347) — thanks @ntdung6868)
|
||||
- **fix(providers): a deactivated account is labeled distinctly from a revoked token** — a Codex connection whose OAuth refresh is fully healthy but whose ChatGPT account has been deactivated by the provider gets a `401` from the upstream API. The connection test labeled that the same as a bad credential (`Token invalid or revoked` → `upstream_auth_error`), so the operator couldn't tell a deactivated account from a revoked token. The test now reads the `401`/`403` body and, when it indicates account deactivation, classifies it as `account_deactivated` — which the dashboard already renders as "Account Deactivated". A plain auth `401` is unchanged. ([#4353](https://github.com/diegosouzapw/OmniRoute/pull/4353) — thanks @ntdung6868)
|
||||
- **fix(db): cascade-delete orphaned model aliases when a provider is removed** — deleting a custom provider removed its connections and node but left behind the imported model-alias rows (stored as `key=<alias>`, `value="<providerId>/<model>"`). Those stale aliases then blocked re-importing the same provider — the import dedup treated them as "already exists", so no new models appeared. A new `deleteModelAliasesForProvider(providerId)` DB helper drops every alias whose stored value begins with `<providerId>/` (leaving other providers and user-defined settings aliases untouched), and the provider-node DELETE handler now calls it after removing the connections and node, so a fresh import is unblocked. ([#4348](https://github.com/diegosouzapw/OmniRoute/pull/4348) — thanks @nguyenvanhuy0612)
|
||||
- **fix(api): persist `max_input_tokens` / `max_output_tokens` when adding a custom model** — `POST /api/provider-models` silently dropped the per-model token limits set in the "add custom model" form: the handler destructured the rest of the body but never read `max_input_tokens` / `max_output_tokens`, and `addCustomModel()` had no parameter for them, so the values were thrown away on write. The DB layer (`inputTokenLimit` / `outputTokenLimit`) and the `/v1/models` catalog already round-trip these fields — only the write path was missing. The validation schema now accepts the two optional limits, the handler forwards them, and `addCustomModel()` persists them so a custom model's context/output window survives into the catalog. ([#4349](https://github.com/diegosouzapw/OmniRoute/pull/4349) — thanks @codename-zen)
|
||||
- **fix(plugin): the OpenCode static-catalog plugin prefixes combo/raw model keys with the provider id** — OpenCode's static-catalog reader misdetected the `omniroute` provider: combo keys emitted as `combo/MASTER` were parsed as provider `combo` ("No credentials for provider: omniroute"), while a bare-`MASTER` form was misread as a model with no resolvable provider, and mixed `omniroute/MASTER` + bare-raw keys were rejected by OpenCode's schema. The plugin now emits every combo and raw model key prefixed with the `omniroute` provider id, emits the provider id explicitly, and drops the legacy `combo/` prefix — so the static-catalog reader detects the provider and the auth loader returns the right credentials (the catalog-fetch timeout was also raised so a cold-start server doesn't publish an empty stub). ([#4384](https://github.com/diegosouzapw/OmniRoute/pull/4384) — thanks @herjarsa)
|
||||
|
||||
- **fix(translator): inject placeholder message when Responses API input[] is empty (prevents upstream 400)** — a client (e.g. Fabric-AI) calling `POST /v1/responses` with `input: []` used to be translated into `messages: []`, which every upstream Chat-Completions provider rejects with `400: at least one message is required` (surfaced to the client as a confusing 406). The translator now treats an empty `input[]` the same as an empty string — a placeholder user message is injected so the request is always valid. (thanks @anuragg-saxenaa)
|
||||
- **fix(embeddings): NVIDIA NIM asymmetric embedding models inject the required `input_type`** — NVIDIA NIM asymmetric embedders (e.g. `nvidia/nv-embedqa-e5-v5`) reject requests without an `input_type` parameter with `400 "'input_type' parameter is required"`, but OmniRoute only forwarded `input_type` when the client supplied it — so callers (and OpenAI-style SDKs that don't emit the field) got a hard failure. The embedding registry now carries a model-level default (`input_type: "query"`) for the asymmetric NVIDIA model, and the embeddings handler injects a model's default params into the upstream body **only** when the client didn't already send them — a client-supplied `input_type` (e.g. `"passage"`) is respected unchanged, and symmetric models that carry no default are unaffected. (thanks @hydraromania)
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **fix(security): scope the OAuth callback `postMessage` to a trusted-origin allowlist** — the OAuth callback at `/callback` previously posted `{ code, state, … }` to `window.opener.postMessage(…, "*")` whenever the opener was cross-origin, so a hostile page that opened the well-known redirect URI in a popup could receive the OAuth code/state and complete the flow as the user. The wildcard fallback is replaced with iteration over a fixed allowlist (same-origin + Codex's `localhost:1455` / `127.0.0.1:1455` loopback helper); the browser silently drops `postMessage` to any opener whose origin isn't listed. ([#4372](https://github.com/diegosouzapw/OmniRoute/pull/4372) — ported from 9router#998, thanks @aeonframework / @diegosouzapw)
|
||||
- **fix(mitm): exact host membership in the MITM hosts test (CodeQL false positive)** — `tests/unit/mitm-tool-hosts.test.ts` checked host membership with `Array.includes(host)`, which CodeQL's `js/incomplete-url-substring-sanitization` heuristic misreads as a `String.includes()` URL-substring sanitization test (HIGH false positive). Switched to `.some((h) => h === host)` — identical semantics, no flagged pattern. ([#4386](https://github.com/diegosouzapw/OmniRoute/pull/4386))
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **docs: one-time feature-documentation catch-up (v3.8.20 → v3.8.30)** — reconciled the docs with every user-facing feature shipped since v3.8.20: a new README **✨ What's New** section; new guides for [CLI integrations](docs/guides/CLI-INTEGRATIONS.md), [MITM TPROXY transparent decrypt](docs/security/MITM-TPROXY-DECRYPT.md) and [delegated Anthropic Context Editing](docs/compression/CONTEXT_EDITING.md); refreshed AUTO-COMBO (`auto/<category>:<tier>` + Arena-ELO), API_REFERENCE (`x-omniroute-no-memory`), MEMORY (int8 quantization, off-by-default), RESILIENCE (model-lockout success-decay), RTK, AGENTBRIDGE, TRAFFIC_INSPECTOR, GUARDRAILS, CLOUD_AGENT, ENVIRONMENT; regenerated PROVIDER_REFERENCE (231 providers) and synced the provider count in README/CLAUDE/AGENTS. Going forward this runs every release (generate-release step 6b). ([#4391](https://github.com/diegosouzapw/OmniRoute/pull/4391))
|
||||
- **refactor(chatCore): extract the `checkHeapPressureGuard` leaf (god-file decomposition start)** — first increment of decomposing `chatCore.ts` (~5127 LOC, the hottest path — every chat request flows through `handleChatCore`). The V8 heap-pressure guard at the top of `handleChatCore` (rejects with 503 when `heapUsed` exceeds the shed threshold) is moved to a self-contained, co-located `utils/heapPressure.ts::checkHeapPressureGuard(...)` with no behavior change. ([#4371](https://github.com/diegosouzapw/OmniRoute/pull/4371) — thanks @diegosouzapw)
|
||||
- **refactor(combo): de-dup the exhausted-target skip predicate across both dispatchers** — the byte-identical `#1731`/`#1731v2` pre-check (skip a target already exhausted on the provider/connection within a request) lived in both combo dispatchers; extracted to a shared `combo/comboPredicates.ts` helper. ([#4362](https://github.com/diegosouzapw/OmniRoute/pull/4362) — thanks @diegosouzapw)
|
||||
- **refactor(combo): de-dup the upstream-error exhaustion classification across both dispatchers** — both dispatchers ran a near-identical post-error block classifying the upstream error and updating the exhaustion Sets (`#1731` provider exhausted / `#1731v2` connection error / transient rate-limited); extracted to a shared `combo/targetExhaustion.ts::applyComboTargetExhaustion(...)`. ([#4366](https://github.com/diegosouzapw/OmniRoute/pull/4366) — thanks @diegosouzapw)
|
||||
- **chore(cli): localize CLI / scraping copy and stabilize fetch, memory & coverage handling** — localizes CLI and scraping UX copy plus the Adapta onboarding tutorial (and corrects the CLI Code page title), makes fetch retries honor the start timeout, tightens SSE/response typing, respects configured memory token limits during search, and reduces CI coverage-merge memory by merging V8 data incrementally. ([#4383](https://github.com/diegosouzapw/OmniRoute/pull/4383) — thanks @JxnLexn)
|
||||
- **test(combo): reset circuit breakers between stream-readiness cases (restore green)** — a stream-readiness fallback case failed on the release branch since the cycle-open tip due to test isolation: earlier combo-dispatch cases in the same file deliberately fail `glm` (tripping the module-level provider circuit breaker), and that OPEN state leaked into the next test so `combo.ts` skipped the model. The test now resets the circuit breakers between cases. ([#4396](https://github.com/diegosouzapw/OmniRoute/pull/4396) — thanks @diegosouzapw)
|
||||
- **chore(quality): reconcile the complexity ratchet baseline (1896 → 1900)** — absorbs the small complexity-metric increase from the v3.8.31 `/review-prs` merge batch into `quality-baseline.json` so the ratchet reflects the shipped code (no production change). ([#4410](https://github.com/diegosouzapw/OmniRoute/pull/4410) — thanks @diegosouzapw)
|
||||
- **test/gate: reconcile release-time drift surfaced by the full CI gate** — three already-merged changes left the release branch's full-CI gate red (the per-PR fast gates don't run it): the Gemini `convertOpenAIContentToParts` tests were realigned to the [#4373](https://github.com/diegosouzapw/OmniRoute/pull/4373) HTTP/HTTPS-URL `fileData` pass-through (they still asserted the old warn-and-drop behavior), the `t11` any-budget for `open-sse/executors/base.ts` was raised to 2 with a justification ([#4389](https://github.com/diegosouzapw/OmniRoute/pull/4389) compares `tool_choice` against the string literal `"any"`, not a TS `any` type), and the [#4384](https://github.com/diegosouzapw/OmniRoute/pull/4384) opencode-plugin combos test's net-assert reduction (dropping the obsolete `combo/` namespace) was allowlisted. No production behavior change. (thanks @diegosouzapw)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.30] — 2026-06-20
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(dashboard): category (media serviceKind) filter on the providers page** — `/dashboard/providers` gains a media-category filter row (Image / Video / Music / Text→Speech / Speech→Text / Embedding) that composes with the existing search, free-only and "show configured only" filters. Membership is derived from the backend media registries (a provider that serves a kind is surfaced even if it never declared `serviceKinds`), keeping the UI in lockstep with the backend. ([#4240](https://github.com/diegosouzapw/OmniRoute/issues/4240))
|
||||
- **feat(combo): per-step account allowlist — scope a round-robin/weighted step to a subset of a provider's connections** — a combo model step can now carry a first-class account allowlist so a round-robin (or weighted) strategy is scoped to a chosen subset of a provider's connections (e.g. only `foo1`+`foo2` out of `foo1..foo4`) without hand-pinning one step per account. Empty = the whole active pool (unchanged). When a step both has an allowlist and is tag-routed, the two intersect (most-restrictive wins); a single pinned account still takes precedence. The combo builder's Precision step editor gains an optional "Restrict to accounts" picker. ([#3266](https://github.com/diegosouzapw/OmniRoute/issues/3266))
|
||||
- **feat(providers): add OpenAdapter, dit.ai and TokenRouter as OpenAI-compatible providers** — three community-requested OpenAI-compatible aggregators now register as standard named OpenAI-style providers with live `/v1/models` discovery (the zenmux pattern), falling back to a seeded catalog when the upstream list is unavailable: **OpenAdapter** (`https://api.openadapter.in/v1`, free tier, 70+ open-source models — [#4239](https://github.com/diegosouzapw/OmniRoute/issues/4239)), **dit.ai** (`https://api.dit.ai/v1`, dynamic-pricing router/gateway — [#4155](https://github.com/diegosouzapw/OmniRoute/issues/4155)), and **TokenRouter** (`https://api.tokenrouter.com/v1`, free MiniMax model — [#3841](https://github.com/diegosouzapw/OmniRoute/issues/3841), thanks @FerLuisxd). No custom executor/translator — default OpenAI passthrough.
|
||||
- **feat(api): `x-omniroute-no-memory` request header — per-request opt-out of memory/skills injection** — clients that manage their own context (e.g. their own RAG/memory) can send `x-omniroute-no-memory: true` (mirrors the existing `x-omniroute-no-cache` convention) to skip the gateway injecting up to `memorySettings.maxTokens` (~2k) tokens of memory **and** skills context into that chat request — avoiding the token/cost inflation it otherwise adds on every call. Absent the header, behavior is unchanged. (PRD-2026-06-19-no-memory-header)
|
||||
- **feat(dashboard): MITM tool card lists the exact hosts-file entries to add manually** — the CLI-tools MITM card's "How it works" section now lists the full set of `127.0.0.1 <host>` lines for the selected tool (sourced from the canonical MITM target registry) instead of a single example domain. Users on locked-down machines — where the automatic, sudo-gated hosts-file edit isn't available — can now copy every required entry by hand. (thanks @mrcyclo)
|
||||
- **feat(cli): `omniroute launch-codex` + `setup-codex` — run/configure the Codex CLI against OmniRoute** — a launcher and setup command that point the Codex CLI at an OmniRoute endpoint (remote-mode aware). ([#4270](https://github.com/diegosouzapw/OmniRoute/pull/4270))
|
||||
- **feat(cli): Claude Code launcher + setup — remote mode + profiles** — `omniroute launch`/`setup` for Claude Code with remote-mode support and named connection profiles. ([#4274](https://github.com/diegosouzapw/OmniRoute/pull/4274))
|
||||
- **feat(cli): OpenCode setup — OpenAI-compatible provider + remote-aware plugin** — `setup-opencode` registers OmniRoute as an OpenAI-compatible provider for OpenCode and installs a remote-aware plugin. ([#4277](https://github.com/diegosouzapw/OmniRoute/pull/4277))
|
||||
- **feat(cli): one-command setup for popular AI coding tools** — new `setup-*` commands that configure each tool to talk to OmniRoute: **Cline** ([#4280](https://github.com/diegosouzapw/OmniRoute/pull/4280)), **Kilo Code** ([#4284](https://github.com/diegosouzapw/OmniRoute/pull/4284)), **Continue** ([#4289](https://github.com/diegosouzapw/OmniRoute/pull/4289)), **Cursor** ([#4291](https://github.com/diegosouzapw/OmniRoute/pull/4291)), **Roo Code** ([#4292](https://github.com/diegosouzapw/OmniRoute/pull/4292)), **Crush** ([#4298](https://github.com/diegosouzapw/OmniRoute/pull/4298)), **Goose** ([#4300](https://github.com/diegosouzapw/OmniRoute/pull/4300)), **Qwen Code** ([#4301](https://github.com/diegosouzapw/OmniRoute/pull/4301)), **Aider** ([#4302](https://github.com/diegosouzapw/OmniRoute/pull/4302)) and the **Gemini CLI** (native `/v1beta`) ([#4303](https://github.com/diegosouzapw/OmniRoute/pull/4303)).
|
||||
- **feat(providers): provider model sweep — live discovery, refreshed catalogs, dead-provider cleanup** — a broad sweep that enables live `/v1/models` discovery for more OpenAI-style providers (the zenmux pattern), refreshes the seeded catalogs with current models, and marks dead providers `deprecated`. ([#4324](https://github.com/diegosouzapw/OmniRoute/pull/4324))
|
||||
- **feat(mitm): translate Antigravity cloudcode end-to-end (Gap B)** — the MITM decrypt path now translates Antigravity `cloudcode` traffic end-to-end. ([#4299](https://github.com/diegosouzapw/OmniRoute/pull/4299))
|
||||
- **feat(keys): per-key USD usage quota controls** — an API key can now carry a USD spend quota that caps its usage once the threshold is reached. ([#4327](https://github.com/diegosouzapw/OmniRoute/pull/4327) — thanks @Witroch4)
|
||||
|
||||
### 🔧 Changed
|
||||
|
||||
- **change(memory): memory is now OFF by default** — `DEFAULT_MEMORY_SETTINGS.enabled` now defaults to `false`. Enabling memory injects up to ~2,000 tokens of retrieved context into **every** chat request (and that context is billed), which was a surprising default for new installs and for clients with their own context. Memory is now an explicit opt-in: installs that already enabled it keep it on; installs that never configured it default to off. The Settings → Memory panel now shows a token-cost warning when memory is enabled. (PRD-2026-06-19-no-memory-header)
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(translator): Gemini accepts HTTP/HTTPS image URLs (no longer silently dropped)** — OpenAI-style `image_url` parts whose URL was `http://…` or `https://…` reached `convertOpenAIContentToParts` (the OpenAI→Gemini request helper) and were dropped with only a `console.warn`, because Gemini's `inlineData` requires base64 and the helper is synchronous (it cannot fetch + encode). Gemini's `Part` schema, however, natively accepts `fileData: { fileUri }` for remote URIs — the model fetches the asset itself. The helper now emits a `fileData` part (`mimeType: "image/*"`, inferred upstream on fetch) instead of dropping, so vision requests that pass a URL — not a data: URI — now reach Gemini intact. `data:` URIs still go through `inlineData` unchanged; unsupported schemes (e.g. `ftp:`) are still skipped. (thanks @East-rayyy)
|
||||
- **fix(security): OAuth callback page no longer relays `code`/`state` to a wildcard `postMessage` target** — the OAuth callback at `/callback` posted `{ code, state, ... }` to `window.opener.postMessage(..., "*")` whenever the opener was cross-origin (a fallback for the legitimate remote-dashboard + local-loopback callback scenario). A hostile page that opened the callback URL in a popup against the well-known redirect URI would therefore receive the OAuth code+state and could complete the OAuth flow as the user. The wildcard fallback is replaced with an iteration over a fixed allowlist of trusted target origins (same-origin + Codex's loopback helper at `localhost:1455` / `127.0.0.1:1455`); the browser silently drops the message for any opener whose origin is not in the list. Methods 2 (`BroadcastChannel`) and 3 (`localStorage`) — already in the page — still cover same-origin parents when the opener was severed by COOP. (thanks @aeonframework)
|
||||
- **fix(compliance): startup cleanup honors the dashboard data-retention setting instead of always trimming to 7 days** — on every restart, `cleanupExpiredLogs()` (run at startup) read retention only from the `CALL_LOG_RETENTION_DAYS` / `APP_LOG_RETENTION_DAYS` env vars, which default to **7 days** when unset, and trimmed `usage_history` (the Usage Analysis data) before the dashboard-based `runAutoCleanup()` — which respects the configured retention — ever ran. So a dashboard "Data Retention" of 90 days was silently overridden and the Usage Analysis page only ever showed the last 7 days after a restart. Retention now follows the precedence **explicit env var → dashboard DB setting → 7-day default**, per table (`usage_history`→`usageHistory`, `call_logs`/`proxy_logs`/`request_detail_logs`→`callLogs`, `mcp_tool_audit`→`mcpAudit`); an operator who sets the env var still wins, and non-DB deployments still fall back to it. ([#4354](https://github.com/diegosouzapw/OmniRoute/issues/4354) — thanks @akbardwi)
|
||||
- **fix(providers): bailian-coding-plan static fallback catalog matches the registry (10 models)** — the provider-model sweep (#4324) added four current Model Studio coding-plan models (`qwen3.7-plus`, `qwen3-coder-plus`, `qwen3-coder-next`, `glm-4.7`) to the `bailian-coding-plan` registry entry but missed the static fallback mirror in `staticModels.ts`, which still listed only the older six. The static catalog (served when live discovery is unavailable) therefore diverged from the registry, and the existing static↔registry parity test went red on the release branch (only surfacing when test-impact analysis happened to select it). The static mirror now carries all ten models in registry order, restoring parity. ([#4324](https://github.com/diegosouzapw/OmniRoute/pull/4324))
|
||||
- **fix(executors): ArenaLLM accepts LMArena's split Supabase SSR auth cookie** — LMArena migrated to `@supabase/ssr` chunked auth cookies: the single `arena-auth-prod-v1` cookie is now empty and the real session is split across `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). A user who pasted the (now-empty) single cookie therefore sent an empty session and upstream rejected it as "invalid cookie". The LMArena executor now reconstructs the single cookie from its chunks — reading `.0`, `.1`, … in ascending numeric order until one is missing and concatenating their raw values (`@supabase/ssr`'s `combineChunks` rule: plain `join("")`, no base64-decode, no JSON-parse, the `base64-` prefix kept verbatim) — while preserving the rest of the pasted jar. A non-empty single cookie is still forwarded unchanged (back-compat). The credential UX now instructs pasting the **full Cookie header** and tracks the `.0`/`.1` storage keys. ([#4271](https://github.com/diegosouzapw/OmniRoute/issues/4271) — thanks @caussao)
|
||||
- **fix(compression): preserve the cacheable prefix for automatic-cache providers** — OpenAI / Codex (and Azure-OpenAI) use _automatic_ prefix caching: the upstream caches the longest matching prefix of a request (system prompt + earliest messages) **without** any explicit `cache_control` markers in the body. The cache-aware compression guard only protected that prefix when the request carried explicit `cache_control`, so for automatic-cache providers the guard was skipped — and with compression enabled and `preserveSystemPrompt: false` (or a prefix-compressing mode like `aggressive`/`ultra`) it rewrote the system prompt / earliest messages, guaranteeing a cache miss and **higher** token spend through OmniRoute than going direct. The guard now treats a caching provider as sufficient on its own (`isCachingProvider` alone, independent of `cache_control`) to skip the system prompt and downgrade prefix-compressing modes, and OpenAI/Codex/Azure are now recognized as caching providers. Compression is still off by default — this only affects operators who enabled it with prefix preservation turned off. ([#3955](https://github.com/diegosouzapw/OmniRoute/issues/3955))
|
||||
- **fix(executors): DuckDuckGo AI Chat uses duckduckgo.com (fixes 400)** — the DuckDuckGo AI Chat executor fetched status/chat and set `Origin`/`Referer` against `https://duck.ai` while still sending `Sec-Fetch-Site: same-origin`, so the request's same-origin triplet (host + Origin + Referer) was inconsistent and the backend rejected it with HTTP 400. All current DDG reverse-engineering references — and the provider registry's own `baseUrl` — use `https://duckduckgo.com`; the executor now uses it consistently for the status URL, chat URL, `Origin`, and `Referer` (the same-origin header is now coherent). The `x-fe-version` scrape regex also required a 40-hex tail but the real served token has a 20-hex tail (e.g. `serp_20250401_100419_ET-19d438eb199b2bf7c300`), so it silently fell back to a hardcoded default; the pattern is relaxed to a bounded `{20,40}` tail (still ReDoS-safe). This addresses the DuckDuckGo half of the report; the separate Chipotle/`chipotle` upstream breakage is tracked independently. ([#4037](https://github.com/diegosouzapw/OmniRoute/issues/4037) — thanks @daniij)
|
||||
- **fix(security): bound the prompt-injection scan to the first 16 KB (hot-path perf)** — the prompt-injection guard joined every message/system string into one buffer and ran several regexes over the **whole** thing on every chat request, with no size cap — so a 300 KB body (pasted code, RAG context) meant O(body) CPU scanning on the hot path, a self-inflicted latency/GC source under concurrency. Both detection call sites (`detectInjection` in `inputSanitizer.ts` and the custom-pattern scan in `promptInjection.ts`) now slice the joined text to the first **16 KB** (`MAX_INJECTION_SCAN_BYTES`) before the regex loop. Injection directives sit near the top of a prompt, so the generous cap preserves real detection while scanning only a bounded prefix; the existing 10 MB body-size cap (which protects ingestion) is unchanged. ([#3932](https://github.com/diegosouzapw/OmniRoute/issues/3932) — thanks @KooshaPari)
|
||||
- **fix(sse): retry direct-connection socket failures on a fresh socket (fewer `502` bursts)** — the default direct-connection undici dispatcher pools keep-alive sockets for up to 4 s, but some edges (e.g. `nvidia`, `opencode-zen`) silently close idle keep-alive sockets within that window, so the next request reusing a pooled socket fails with `UND_ERR_SOCKET` ("other side closed") — in bursts. `proxyFetch` already retried once on such transient errors, but the retry reused the **same** pooled dispatcher and could grab another stale socket, then fell through to native fetch (which also pools) → the job sat in the rate-limit queue until the 30 s timeout → `502` + circuit-breaker open. The retry now uses a dedicated **no-keep-alive / no-pipelining** dispatcher so it opens a brand-new socket that can't be a dead pooled one; the first attempt still uses the pooled dispatcher (healthy keep-alive reuse is preserved). Complements the v3.8.29 diagnostics (`describeFetchCause`, #4281). ([#4252](https://github.com/diegosouzapw/OmniRoute/issues/4252) — thanks @klimadev)
|
||||
- **fix(sse): combo now stops at the first body-specific 400 instead of trying every target** — the `#2101` guard that detects a body-specific 400 (context overflow / malformed / model-access-denied, e.g. "model is not supported when using Codex with a ChatGPT account") logged "stopping combo" but executed a bare `break`, which only exited the inner retry loop; `executeTarget` then returned `null` and the outer target loop treated that as "this target produced nothing" and advanced to the next model. A combo of N targets that all reject the same request body therefore marched through all N (the report shows a 143-model Codex combo iterating every target), wasting upstream calls and per-attempt work. The guard now surfaces the 400 via the `{ ok, response }` contract (mirroring the 499 client-disconnect path) so the combo resolves and stops immediately. ([#4279](https://github.com/diegosouzapw/OmniRoute/issues/4279))
|
||||
- **fix(sse): non-streaming combo over a Responses-API target no longer returns empty content** — a Responses-API target (codex/`cx`) streams from upstream even on `stream:false`, and its terminal `response.completed` snapshot can carry a non-empty `output` that lacks the assistant message item (e.g. only a `reasoning` item) while the streamed `output_text` deltas had reconstructed the full message. The SSE→JSON aggregator preferred the terminal `output` wholesale, dropping the reconstructed text → HTTP 200 with empty content (hit notably via n8n, which defaults to `stream:false`). The aggregator now falls back to the reconstructed delta output when the terminal output has no message item but the reconstruction does; the terminal snapshot still wins whenever it already carries the message. ([#3948](https://github.com/diegosouzapw/OmniRoute/issues/3948))
|
||||
- **fix(executors): preserve tool-name casing on native Claude OAuth (`read` no longer leaks back as `Read`)** — native Claude OAuth traffic runs through an anti-fingerprint tool-name cloak that renames a tool literally named `read` to `Read` on the wire and records the reverse alias on a non-enumerable `_toolNameMap`, which the response side uses to restore the client's original casing. Since v3.8.27 the executor returned a JSON-round-tripped copy of the body as `transformedBody`, and that round-trip dropped the non-enumerable map — so the restore saw an empty map and the cloaked `Read` streamed verbatim to the client, corrupting the tool name. The executor now re-attaches the cloak map onto the serialized body (mirroring the Antigravity executor), so tool-name casing round-trips correctly. ([#4307](https://github.com/diegosouzapw/OmniRoute/issues/4307) — thanks @dev-cj)
|
||||
- **fix(api): cache-HIT `X-OmniRoute-Response-Cost` now reports the incremental cost (≈0), not the original** — on a semantic-cache HIT the gateway serves the stored response **without** an upstream call, but `X-OmniRoute-Response-Cost` was reporting the original call's full cost (recomputed from the cached `usage`). A consumer summing `response-cost` for billing was therefore charging for responses that cost ≈$0 to serve (and stale entries could inflate it). Cache hits now bill `X-OmniRoute-Response-Cost: 0.0000000000` (the real incremental cost), and the avoided cost is surfaced in a new **`X-OmniRoute-Cost-Saved`** header for cache analytics — mirroring the existing `tokens_saved` concept. The MISS path is unchanged. (PRD-2026-06-19-cache-hit-cost-reporting)
|
||||
- **fix(models): imported vision-capable models keep their vision capability** — after importing a provider key, vision-capable models (e.g. OpenRouter models whose `architecture` declares image input, and other synced providers) were listed as text-only in `/v1/models` and the dashboard — even though image requests actually worked. Synced model records never captured the vision flag, and the catalog's OpenRouter live-enrichment (which derives vision from `architecture.input_modalities`) is skipped once a provider has synced models. Discovery now captures `supportsVision` at sync time (from `architecture.input_modalities`, the string `architecture.modality`, or a top-level `input_modalities`), mirroring the existing `supportsThinking` capture, and the catalog surfaces `capabilities.vision` for synced models. ([#4264](https://github.com/diegosouzapw/OmniRoute/issues/4264) — thanks @FerLuisxd)
|
||||
- **fix(providers): Cloudflare Workers AI model discovery shows model names, not UUIDs** — importing a Cloudflare Workers AI key listed models with internal UUID identifiers (e.g. `429b9e8b-d99e-…`) instead of their usable slugs (`@cf/meta/llama-3.1-8b-instruct`). Cloudflare's `/ai/models/search` returns `{ id: "<uuid>", name: "@cf/…" }`, and discovery was passing the raw objects through — so the UUID `id` became the callable model id. The `cloudflare-ai` discovery now maps each result's `name` → id, surfacing the real `@cf/…` model ids. ([#4259](https://github.com/diegosouzapw/OmniRoute/issues/4259) — thanks @FerLuisxd)
|
||||
- **fix(translator): clamp Responses API `call_id` to 64 characters** — the OpenAI Responses API rejects `call_id` values longer than 64 characters with a 400. Long upstream tool-call ids (some clients emit ids well over the limit) are now clamped deterministically on both the `function_call` item and its matching `function_call_output`, so the pair stays matched through the orphaned-output filter and the request is accepted. (thanks @anuragg-saxenaa, @ngapngap)
|
||||
- **fix(oauth): GitHub Copilot token refresh now sends the public client_id** — the `github` provider config never carried a `clientId`, so GitHub OAuth `refresh_token` exchanges either omitted `client_id` or sent the literal string `undefined` (and a bogus `client_secret=undefined`), which GitHub rejects — leaving a Copilot connection stuck once its short-lived token expired and the long-lived refresh path was needed. The provider now resolves its public device-flow `client_id` from the embedded public credential and omits `client_secret` entirely (GitHub's Copilot app is a public client with no secret). (thanks @baslr)
|
||||
- **fix(translator): a tool property named `pattern` survives Gemini/Antigravity schema sanitization** — the Gemini schema sanitizer strips JSON-Schema constraint keywords Gemini rejects (`pattern`, `minLength`, …) at every nesting level, but it also deleted any tool **property** literally _named_ one of those keywords. glob/grep tools declare a property called `pattern`, so on `ag/*` (Antigravity) backends that argument (and its `required` entry) was silently dropped, breaking the tools. Keyword stripping is now position-aware: it only removes constraint keywords at the schema-node level and never against the user-defined names inside a `properties` map. A genuine string-level `pattern` _constraint_ is still stripped. (thanks @youthanh)
|
||||
- **fix(translator): MCP `namespace` tools flatten to individual functions on the Responses→Chat path** — when a Codex CLI client routes a Responses-API request to a non-Codex backend (e.g. `kr/claude-opus-4.7`), each MCP server is declared as a `namespace` tool (`{ type:"namespace", name, tools:[…] }`). The Responses→Chat translator had no `namespace` branch, so the whole group collapsed into a single empty-schema function named `mcp__<server>__` and every MCP call returned `unsupported call: mcp__<server>__`, breaking all MCP-based workflows (context7, codegraph, custom MCPs) for that combination. The translator now expands a namespace into one Chat function per sub-tool (preserving each sub-tool's name and parameters); an empty namespace yields no tools instead of a broken placeholder. The native Codex passthrough path was already correct. (thanks @V13t4nh)
|
||||
- **fix(cli): the active remote-context credential wins over an ambient `OMNIROUTE_API_KEY`** — when a remote context is selected, its scoped access token now takes precedence over an `OMNIROUTE_API_KEY` present in the environment, so the connected remote is targeted as expected. ([#4364](https://github.com/diegosouzapw/OmniRoute/pull/4364))
|
||||
- **fix(cli): wire the `contexts` command into the CLI program** — the `omniroute contexts` command (list/switch saved remote contexts) was implemented but never registered, so it was unreachable; it is now wired into the CLI program. ([#4369](https://github.com/diegosouzapw/OmniRoute/pull/4369))
|
||||
- **fix(mitm): mask bare `Bearer <token>` header values in the Traffic Inspector** — the inspector now redacts bare `Authorization: Bearer …` values so tokens don't leak into captured traffic. ([#4358](https://github.com/diegosouzapw/OmniRoute/pull/4358))
|
||||
- **fix(pricing): price the `gpt-5.x-pro` OpenAI models + align the opencode-go discovery test** — adds pricing for the gpt-5.x-pro models so cost telemetry reports a real cost instead of zero. ([#4355](https://github.com/diegosouzapw/OmniRoute/pull/4355))
|
||||
- **fix(sse): release the reader and cancel the stream on abort/error (no more Undici pool socket leak)** — on abort or a mid-stream error the response reader is released and the stream cancelled, preventing leaked pooled sockets that degraded later requests. ([#4309](https://github.com/diegosouzapw/OmniRoute/pull/4309) — thanks @Ardem2025)
|
||||
- **fix(kiro): emit an early role-only start chunk to release the stream-readiness gate** — Kiro streams now send an initial role-only chunk so the stream-readiness gate releases promptly instead of stalling. ([#4311](https://github.com/diegosouzapw/OmniRoute/pull/4311) — thanks @artickc)
|
||||
- **fix(dashboard): the proxy modal stops pre-filling new scopes with an unrelated proxy** — adding a new scope assignment no longer inherits a previously-selected proxy's configuration. ([#4312](https://github.com/diegosouzapw/OmniRoute/pull/4312))
|
||||
- **fix(open-sse): inner-ai stops silently rerouting unmatched models to `models[0]`** — an unmatched model id is no longer silently served by the first available model; the lookup now returns null and the request is handled explicitly. ([#4310](https://github.com/diegosouzapw/OmniRoute/pull/4310))
|
||||
- **fix(pollinations): handle auth-required premium models (claude, gemini, midjourney)** — premium Pollinations models that require authentication are now handled correctly instead of failing. ([#4266](https://github.com/diegosouzapw/OmniRoute/pull/4266) — thanks @oyi77)
|
||||
- **fix(codex): isolate the Spark quota scope** — Codex Spark usage is tracked under its own quota scope so it no longer bleeds into other Codex quotas. ([#4293](https://github.com/diegosouzapw/OmniRoute/pull/4293) — thanks @xz-dev)
|
||||
- **fix(dashboard): improve the API "try it" functionality** — fixes the request path used by the dashboard's API "try it" panel. ([#4296](https://github.com/diegosouzapw/OmniRoute/pull/4296) — thanks @edrickrenan)
|
||||
- **fix: polyfill `crypto.randomUUID` for non-secure contexts** — restores UUID generation when the dashboard is served over a non-secure (plain-HTTP) origin where `crypto.randomUUID` is unavailable. ([#4287](https://github.com/diegosouzapw/OmniRoute/pull/4287) — thanks @pizzav-xyz)
|
||||
- **fix(proxy): allow concurrent proxy dispatcher streams** — the proxy dispatcher no longer serializes streams, so concurrent requests through a proxied connection run in parallel. ([#4288](https://github.com/diegosouzapw/OmniRoute/pull/4288) — thanks @wilsonicdev)
|
||||
- **fix(build): co-locate llmlingua SLM optionals into `dist/node_modules` (postinstall)** — the optional llmlingua SLM packages are co-located into the standalone build so the compression worker can actually spawn in production. ([#4286](https://github.com/diegosouzapw/OmniRoute/pull/4286))
|
||||
- **fix(mitm): surface AgentBridge traffic in the Traffic Inspector (D4 ingest)** — AgentBridge requests now appear in the Traffic Inspector. ([#4285](https://github.com/diegosouzapw/OmniRoute/pull/4285))
|
||||
- **fix(sse): surface undici `err.cause` on dispatcher failure** — dispatcher failures now flatten the cause chain (and `AggregateError`s) into the error detail for diagnosability. ([#4281](https://github.com/diegosouzapw/OmniRoute/pull/4281))
|
||||
- **fix(cli): harden `launch`/`launch-codex` with free-claude-code patterns** — the launchers adopt the hardened launch patterns ported from free-claude-code. ([#4278](https://github.com/diegosouzapw/OmniRoute/pull/4278))
|
||||
- **fix(compression): end-to-end audit — fixes across the whole compression flow** — a sweep of the compression pipeline fixing ultra/aggressive/lossless edge cases, accessibility-anchor handling, language detection, and mode decoupling. ([#4323](https://github.com/diegosouzapw/OmniRoute/pull/4323))
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- **test: align two tests left red by merged PRs** — re-aligns the db-rules classification count (#4335) and the LMArena split-cookie metadata test (#4271) after concurrent merges. ([#4346](https://github.com/diegosouzapw/OmniRoute/pull/4346))
|
||||
- **test(ci): reconcile the release/v3.8.30 baseline + test drift** — reconciles quality baselines and drifted tests accumulated on the release branch. ([#4276](https://github.com/diegosouzapw/OmniRoute/pull/4276))
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **refactor(combo): `ComboContext` + extract `phaseComboSetup` (god-file split, phase 1)** — begins decomposing the combo god-file by extracting combo setup into a context object, without touching dispatch/semaphore logic. ([#4326](https://github.com/diegosouzapw/OmniRoute/pull/4326))
|
||||
- **feat(quality): cap test-file size — anti-reinflation Layer 1** — freezes the existing god-tests and caps new test files at 800 lines to stop re-inflation. ([#4273](https://github.com/diegosouzapw/OmniRoute/pull/4273))
|
||||
- **feat(quality): seed per-module mutationScore floors + a blocking aggregation ratchet (T3)** — adds per-module mutation-score floors with a blocking aggregate gate. ([#4305](https://github.com/diegosouzapw/OmniRoute/pull/4305))
|
||||
- **feat(quality): make the a11y gate real (`@axe-core/playwright` in nightly)** — wires the previously-phantom accessibility gate into the nightly run with real baselines. ([#4321](https://github.com/diegosouzapw/OmniRoute/pull/4321))
|
||||
- **feat(quality): unblock R1 — test-redundancy measurement via `disableBail`** — enables the test-redundancy measurement that was previously blocked by fail-fast. ([#4322](https://github.com/diegosouzapw/OmniRoute/pull/4322))
|
||||
- **fix(quality): the complexity gate now covers `bin/` + `electron/`, and tracked-artifacts runs in pre-commit** — extends the complexity gate's scope and moves the tracked-artifacts check into the pre-commit hook. ([#4318](https://github.com/diegosouzapw/OmniRoute/pull/4318))
|
||||
- **fix(quality): restore release/v3.8.30 green — 3 latent reds from concurrent merges** — fixes three latent test reds surfaced by concurrent merges into the release branch. ([#4335](https://github.com/diegosouzapw/OmniRoute/pull/4335))
|
||||
- **fix(combo): keep `phaseComboSetup` under the complexity ceiling** — extracts a helper so the new combo setup phase stays under the complexity gate. ([#4338](https://github.com/diegosouzapw/OmniRoute/pull/4338))
|
||||
- **ci(mutation): split over-budget batches by range/pair so every batch fits the job cap** — re-splits the mutation batches so each fits the CI job budget. ([#4272](https://github.com/diegosouzapw/OmniRoute/pull/4272))
|
||||
- **chore(ci): align the electron audit gate to the root advisory policy** — the electron-workspace audit gate now follows the same advisory policy as the root. ([#4275](https://github.com/diegosouzapw/OmniRoute/pull/4275))
|
||||
- **chore(quality): reconcile the complexity/quality baselines across concurrent-merge drift** — rolls up the cycle's baseline reconciliations driven by concurrent merges into the release branch. ([#4330](https://github.com/diegosouzapw/OmniRoute/pull/4330), [#4336](https://github.com/diegosouzapw/OmniRoute/pull/4336), [#4370](https://github.com/diegosouzapw/OmniRoute/pull/4370))
|
||||
- **docs: ban AI-generation footers in commits/PRs/CHANGELOG (Hard Rule #16)** — codifies the prohibition on AI-generation footers and bot co-author trailers. ([#4328](https://github.com/diegosouzapw/OmniRoute/pull/4328))
|
||||
- **docs(design): add the OmniRoute design system and visual identity specification** — adds the design-system / visual-identity specification document. (thanks @diegosouzapw)
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **fix(sse): harden the DuckDuckGo lite scraper sanitization (CodeQL)** — closes four HIGH CodeQL alerts in the no-key web-search scraper: `decodeEntities` now resolves `&` **last** so an already-escaped entity (e.g. `&lt;`) survives as literal text instead of being double-unescaped (`js/double-escaping`); `stripTags` decodes entities first, then strips tags in a loop to a fixpoint and drops any trailing unclosed `<…`, so entity-encoded markup like `<script>` can never reach the LLM/client as a live tag (`js/incomplete-multi-character-sanitization`); and the host checks in the search tests use `new URL().hostname` equality instead of substring `.includes` (`js/incomplete-url-substring-sanitization`). ([#4356](https://github.com/diegosouzapw/OmniRoute/pull/4356))
|
||||
|
||||
### 🔧 Dependencies
|
||||
|
||||
- **fix(deps): bump undici to 7.28.0 and dompurify to 3.4.11 (security)** — addresses the undici SOCKS5-TLS / cache advisories and the dompurify advisory. ([#4306](https://github.com/diegosouzapw/OmniRoute/pull/4306))
|
||||
- **chore(deps): bump actions/checkout from 4 to 7** — CI checkout-action update. ([#4297](https://github.com/diegosouzapw/OmniRoute/pull/4297))
|
||||
- **fix(executors): strip `stream_options` for qwen non-streaming / thinking-mode Claude Code requests** — Claude-Code-compatible providers force the executor-level `stream` flag on via `upstreamStream = stream || isClaudeCodeCompatible` (`open-sse/handlers/chatCore.ts`), but the outgoing body keeps the caller's original `stream: false`. The shared `stream && targetFormat === "openai"` branch in `DefaultExecutor.transformRequest` then injected `stream_options: { include_usage: true }` onto a body that still said `stream: false`, and qwen upstream rejected it with `400 "'stream_options' only set this when you set stream: true"`. Same rejection when the body carries `thinking` / `enable_thinking`. The qwen branch now skips the injection (and strips any client-sent `stream_options`) when the body explicitly says `stream: false` or requests thinking, leaving regular qwen streaming requests with the usage injection intact. (thanks @anuragg-saxenaa)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.29] — 2026-06-19
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(cloud-agent): Cursor Cloud Agent via the official API-key REST API (no IDE-OAuth ban risk)** — adds a `cursor-cloud` cloud agent that drives Cursor's Background / Cloud Agents through the official REST API (`api.cursor.com`) authenticated with a user or service-account API key — the safer, first-party alternative to re-using the Cursor IDE's OAuth session (the existing `cursor` provider, which carries a ban-risk warning). Implemented as a plain REST adapter mirroring the Devin/Jules agents (`createTask`/`getStatus`/`sendMessage`/`listSources`), so it does **not** pull in the `@cursor/sdk` package and its per-platform native binaries (Cursor's SDK is itself a thin wrapper over this REST API). Cursor's UPPERCASE status enums (`CREATING`/`RUNNING`/`FINISHED`/`ERROR`) are mapped explicitly to the shared `CloudAgentStatus`, and `baseUrl` is overridable per-credential. Credentials are stored encrypted via the existing `cloud_agent_credentials` table; no schema change. ([#4227](https://github.com/diegosouzapw/OmniRoute/issues/4227) — thanks @MRDGH2821)
|
||||
- **feat(routing): OpenRouter-style `auto/<category>:<tier>` combos** — auto-routing now understands suffixed combos that separate the _category_ (what kind of route) from the _tier_ (how to optimize): `auto/coding:fast`, `auto/coding:cheap` (alias `:floor`), `auto/coding:free`, `auto/coding:pro`, `auto/coding:reliable`, plus the new category roots `auto/reasoning`, `auto/vision`, `auto/multimodal`. The **tier** picks the scoring weights — `:fast` → ship-fast, `:cheap`/`:floor` → cost-saver, `:reliable` → a new reliability-first pack (circuit-breaker health + latency stability) — while `:free`/`:pro` filter the candidate pool by model tier (`classifyTier`: free-tier vs. premium models). The **category** filters the pool by capability (`vision`/`multimodal` → vision-capable models, `reasoning` → reasoning/thinking models). Any valid `auto/<category>:<tier>` resolves on demand; a curated set is advertised in `/v1/models` and the dashboard. Filtering is fail-open — if a constraint matches no connected models the full pool is used so routing never breaks. All composition lives in the new `open-sse/services/autoCombo/suffixComposition.ts`; the core combo scorer (`combo.ts`) is untouched. Second slice of #4235 (premium account-tier weighting is a later follow-up). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821)
|
||||
- **feat(routing): advertise the `auto/cheap`, `auto/offline`, `auto/smart` combos (catalog ↔ README sync)** — the README lists `auto/cheap` (cheapest-per-token first), `auto/offline` (most quota/rate-limit headroom first) and `auto/smart` (quality-first + 10% exploration), and they already resolved at request time via `parseAutoPrefix` → `createVirtualAutoCombo`. But they were missing from `AUTO_TEMPLATE_VARIANTS`, so `/v1/models` and the dashboard combos list (which iterate that catalog) never showed them — the catalog drifted from the docs (visible in the issue's screenshots). Added the three entries so they're advertised everywhere alongside the other built-in `auto/*` combos. First slice of #4235 (OpenRouter-style `auto/<category>:<tier>` suffixes + new categories follow). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821)
|
||||
- **feat(cli): remote mode — drive a remote OmniRoute with scoped access tokens** — a new CLI mode that connects to a remote OmniRoute instance using scoped access tokens, so a local CLI can drive a server you don't own a session on. ([#4256](https://github.com/diegosouzapw/OmniRoute/pull/4256))
|
||||
- **feat(api): cost-telemetry parity — `X-OmniRoute-*` headers on every endpoint + a non-token cost engine** — every endpoint now emits the `X-OmniRoute-*` cost/usage headers, backed by a cost engine that also prices non-token (media/request-based) usage. ([#4247](https://github.com/diegosouzapw/OmniRoute/pull/4247))
|
||||
- **feat(api): register Kimi K2.7 Code models (`kimi-k2.7-code` + `-highspeed`)** — the new Moonshot thinking-only coding models are registered (fixed sampling; `temperature`/`top_p` marked unsupported). ([#4183](https://github.com/diegosouzapw/OmniRoute/pull/4183))
|
||||
- **feat(catalog): add `kimi-k2.7-code` to the kmca catalog + qwen-web models discovery** — surfaces the new Kimi coding model in the kmca catalog and wires qwen-web into model discovery. ([#4185](https://github.com/diegosouzapw/OmniRoute/pull/4185))
|
||||
- **feat(api): expand the `zai` provider catalog with GLM-5.2 / GLM-4.7** — adds the real GLM-5.2, GLM-4.7 and GLM-4.7-flash model ids to the Anthropic-direct `zai` provider. ([#4201](https://github.com/diegosouzapw/OmniRoute/pull/4201))
|
||||
- **feat(api): no-thinking gateway model IDs (FCC port, Fase 8.1)** — gateway model id variants that force thinking off, ported from free-claude-code. ([#4145](https://github.com/diegosouzapw/OmniRoute/pull/4145))
|
||||
- **feat(sse): mid-stream continuation for truncated streams (FCC port, Task 4.4)** — when a stream is cut short, OmniRoute can transparently continue it, ported from free-claude-code. ([#4147](https://github.com/diegosouzapw/OmniRoute/pull/4147))
|
||||
- **feat(sse): per-provider sliding-window rate-limit fallback (FCC port, Fase 8.2)** — a per-provider sliding-window rate limiter as a fallback path, ported from free-claude-code. ([#4146](https://github.com/diegosouzapw/OmniRoute/pull/4146))
|
||||
- **feat(sse): transparent stream recovery (FCC port, Fase 4, opt-in)** — opt-in transparent recovery of interrupted upstream streams, ported from free-claude-code. ([#4131](https://github.com/diegosouzapw/OmniRoute/pull/4131))
|
||||
- **feat(search): free DuckDuckGo web search as a last-resort provider (FCC port, Fase 6)** — adds a no-key DuckDuckGo web-search provider used as a last resort, ported from free-claude-code. ([#4136](https://github.com/diegosouzapw/OmniRoute/pull/4136))
|
||||
- **feat(logging): credential-redaction safety net in the pino logger (FCC port, Fase 8.3)** — a logger-level redaction pass that scrubs credentials from log output, ported from free-claude-code. ([#4140](https://github.com/diegosouzapw/OmniRoute/pull/4140))
|
||||
- **feat(memory): opt-in Qdrant scalar int8 quantization (F4.4 Q1)** — opt-in int8 scalar quantization for Qdrant-backed memory vectors. ([#4187](https://github.com/diegosouzapw/OmniRoute/pull/4187))
|
||||
- **feat(memory): opt-in sqlite-vec int8 vector quantization (F4.4 Q2)** — opt-in int8 quantization for the sqlite-vec memory backend. ([#4190](https://github.com/diegosouzapw/OmniRoute/pull/4190))
|
||||
- **feat(deploy): keep optional deps on `update` (`--include=optional`)** — the in-place update path now passes `--include=optional` so native/optional packages aren't dropped on update. ([#4260](https://github.com/diegosouzapw/OmniRoute/pull/4260))
|
||||
- **feat(dashboard): unified visual identity — grid, primitives, tables, form controls (design phases 1-4)** — a sweeping design pass aligning the dashboard with the site: grid wallpaper, button/card/input primitives, theme-aware tables and form controls. ([#4122](https://github.com/diegosouzapw/OmniRoute/pull/4122))
|
||||
- **feat(dashboard): grid wallpaper on all standalone screens + fluid 4K layout** — the identity grid now backs every standalone screen and the layout scales fluidly to 4K. ([#4158](https://github.com/diegosouzapw/OmniRoute/pull/4158))
|
||||
- **feat(dashboard): make the identity grid visible + unify the focus ring on accent** — design follow-up making the grid actually visible and standardizing focus rings on the accent color. ([#4141](https://github.com/diegosouzapw/OmniRoute/pull/4141))
|
||||
- **feat(dashboard): import only free models + free-model list controls** — the model-import page can import just the free models, with controls to manage the free-model list. ([#4176](https://github.com/diegosouzapw/OmniRoute/pull/4176) — thanks @felipesartori)
|
||||
- **feat(dashboard): compact grid layout for no-auth provider accounts** — a denser grid layout for provider accounts when auth is disabled. ([#4137](https://github.com/diegosouzapw/OmniRoute/pull/4137) — thanks @felipesartori)
|
||||
- **feat(dashboard): derive media `serviceKinds` from the registries (surface MiniMax + the media catalog)** — `/media-providers/[kind]` now derives its service kinds from the registries instead of a hand-maintained list, surfacing ~48 previously-invisible media providers (incl. MiniMax TTS/video/music). ([#4212](https://github.com/diegosouzapw/OmniRoute/pull/4212))
|
||||
- **feat(traffic-inspector): live (in-flight) request filter (Gap 5)** — the Traffic Inspector can filter to in-flight requests as they happen. ([#4130](https://github.com/diegosouzapw/OmniRoute/pull/4130))
|
||||
- **feat(agent-bridge): maintenance & diagnostics dashboard controls** — adds maintenance and diagnostics controls for the Agent Bridge to the dashboard. ([#4127](https://github.com/diegosouzapw/OmniRoute/pull/4127))
|
||||
- **feat(mitm): TPROXY IP_TRANSPARENT native addon + conditional loader (Epic A)** — a native `IP_TRANSPARENT` addon with a conditional loader, the foundation for TPROXY capture. ([#4148](https://github.com/diegosouzapw/OmniRoute/pull/4148))
|
||||
- **feat(mitm): Fase 3 Epic A spike — TPROXY command builder** — a transactional builder for the iptables/TPROXY command set. ([#4139](https://github.com/diegosouzapw/OmniRoute/pull/4139))
|
||||
- **feat(mitm): TPROXY setup layer — transactional apply/revert (Epic A)** — applies and reverts the TPROXY routing setup transactionally. ([#4144](https://github.com/diegosouzapw/OmniRoute/pull/4144))
|
||||
- **feat(mitm): add `setSocketMark` to the TPROXY addon (anti-loop primitive)** — exposes `setSocketMark` so OmniRoute's own egress can be marked and skipped (anti-loop). ([#4160](https://github.com/diegosouzapw/OmniRoute/pull/4160))
|
||||
- **feat(mitm): TPROXY capture-mode listener + `connectMarked` (Epic A)** — the capture-mode listener plus a marked-connect primitive. ([#4169](https://github.com/diegosouzapw/OmniRoute/pull/4169))
|
||||
- **feat(mitm): dynamic per-SNI cert authority for TPROXY (TLS decrypt 1/N)** — a per-SNI on-the-fly certificate authority, the first slice of TLS decrypt. ([#4173](https://github.com/diegosouzapw/OmniRoute/pull/4173))
|
||||
- **feat(mitm): TLS-terminating capture for TPROXY (decrypt 2/N)** — terminates TLS to capture decrypted traffic. ([#4179](https://github.com/diegosouzapw/OmniRoute/pull/4179))
|
||||
- **feat(mitm): wire the TLS decrypt engine into TPROXY capture mode (decrypt 3/N)** — connects the decrypt engine to the capture-mode pipeline. ([#4200](https://github.com/diegosouzapw/OmniRoute/pull/4200))
|
||||
- **feat(mitm): TPROXY capture-mode manager (decrypt 4a/N)** — a manager coordinating the TPROXY capture lifecycle. ([#4208](https://github.com/diegosouzapw/OmniRoute/pull/4208))
|
||||
- **feat(mitm): local-only route + trust-store installer for TPROXY decrypt (4b/N)** — a loopback-only management route plus a CA trust-store installer for the decrypt CA. ([#4211](https://github.com/diegosouzapw/OmniRoute/pull/4211))
|
||||
- **feat(dashboard): TPROXY decrypt capture toggle in the Traffic Inspector (4c/N)** — a UI toggle to enable/disable decrypted capture. ([#4216](https://github.com/diegosouzapw/OmniRoute/pull/4216))
|
||||
- **feat(compression): replace the headroom tabular encoder with a vendored GCF** — swaps the tabular encoder for a vendored GCF implementation. ([#4167](https://github.com/diegosouzapw/OmniRoute/pull/4167) — thanks @blackwell-systems)
|
||||
- **feat(compression): live per-engine streaming via `compression.step` (F3.3)** — streams per-engine compression progress through a `compression.step` event. ([#4217](https://github.com/diegosouzapw/OmniRoute/pull/4217))
|
||||
- **feat(compression): show an engine node for single-engine runs in the studio** — the Compression Studio now renders an engine node even when only one engine runs. ([#4210](https://github.com/diegosouzapw/OmniRoute/pull/4210))
|
||||
- **feat(compression): expose the WaterfallInspector via a Canvas/Waterfall toggle** — adds a Canvas/Waterfall view toggle that surfaces the WaterfallInspector. ([#4238](https://github.com/diegosouzapw/OmniRoute/pull/4238))
|
||||
- **feat(compression): make `mcpAccessibility` config reachable via a settings sub-route** — exposes the `mcpAccessibility` config under a dedicated settings sub-route. ([#4237](https://github.com/diegosouzapw/OmniRoute/pull/4237))
|
||||
- **feat(compression): runnable A/B benchmark CLI (F2.4)** — a CLI to run A/B compression benchmarks. ([#4220](https://github.com/diegosouzapw/OmniRoute/pull/4220))
|
||||
- **feat(compression): add a transcript loader to the replay harness** — the replay harness can now load real transcripts. ([#4246](https://github.com/diegosouzapw/OmniRoute/pull/4246))
|
||||
- **feat(compression): wire MCP tool-cardinality reduction (F4.3, opt-in)** — opt-in reduction of MCP tool-set cardinality to shrink prompts. ([#4221](https://github.com/diegosouzapw/OmniRoute/pull/4221))
|
||||
- **feat(compression): wire RTK comment-stripping config + honor `preserveDocstrings`** — RTK comment-stripping is now configurable and honors a `preserveDocstrings` flag. ([#4242](https://github.com/diegosouzapw/OmniRoute/pull/4242))
|
||||
- **feat(compression): honor the per-filter RTK `deduplicate` flag** — RTK filters now respect a per-filter `deduplicate` flag. ([#4231](https://github.com/diegosouzapw/OmniRoute/pull/4231))
|
||||
- **feat(compression): honor the registry `enabled` flag in the stacked loop** — the stacked compression loop now skips engines disabled in the registry. ([#4244](https://github.com/diegosouzapw/OmniRoute/pull/4244))
|
||||
- **feat(compression): persist RTK grouping config (unlock R5 `enableGrouping`)** — persists the RTK grouping configuration, unlocking the R5 `enableGrouping` rule. ([#4207](https://github.com/diegosouzapw/OmniRoute/pull/4207))
|
||||
- **feat(compression): wire ultra's `modelPath`/`slmFallbackToAggressive` to the LLMLingua SLM tier** — connects the ultra tier's small-language-model knobs to the LLMLingua SLM path. ([#4257](https://github.com/diegosouzapw/OmniRoute/pull/4257))
|
||||
- **feat(quality): Onda 2 mutation-gate tooling — radiography classifier (T1) + `mutationScore` ratchet (T3)** — new mutation-testing tooling: a survivor-radiography classifier and a `mutationScore` ratchet. ([#4234](https://github.com/diegosouzapw/OmniRoute/pull/4234))
|
||||
- **feat(ci): wire the F2.4 compression budget-gate ratchet** — adds a CI ratchet that gates compression budget regressions. ([#4232](https://github.com/diegosouzapw/OmniRoute/pull/4232))
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(providers): qwen-web model discovery now lists the live catalog instead of nothing** — the `qwen-web` cookie provider had no entry in `PROVIDER_MODELS_CONFIG`, so its model-discovery page returned an empty/stale local catalog (the OAuth fallback at the top of the route only fires for `provider === "qwen"`, leaving `qwen-web` to fall through to the no-config branch). Added a `qwen-web` entry that fetches the **public** `https://chat.qwen.ai/api/v2/models` endpoint (no auth header) and parses the `{ data: { data: [{ id, name, owned_by }] } }` shape (with a flatter `{ data: [] }` fallback). This is Problem #3 of #3931 (diagnosed by @thezukiru); Problem #1 — validator bare-token false-positive — shipped earlier in #3958, and Problem #2 — empty stream from Qwen WAF bot-detection on the streaming endpoint — remains a separate upstream/stealth concern. ([#3931](https://github.com/diegosouzapw/OmniRoute/issues/3931) — thanks @thezukiru)
|
||||
- **fix(providers): ZenMux model discovery now lists the live catalog (incl. the free models) instead of the stale 9-entry hardcoded list** — adding a ZenMux key validated fine, but the connection then showed `API unavailable — using local catalog` and was missing the free models ZenMux advertises (`z-ai/glm-5.2-free`, `moonshotai/kimi-k2.7-code-free`). Root cause: `zenmux` carries a correct `modelsUrl` in the registry, but — like `llm7`/`byteplus` before #3976 — it was not classified by any live-fetch branch of the model-import route (not `openai-compatible-*`, not self-hosted, not in `NAMED_OPENAI_STYLE_PROVIDERS`), so the route never probed the upstream `/models` and fell through to the registry's hardcoded `models[]`. Added `zenmux` to `NAMED_OPENAI_STYLE_PROVIDERS`, so the route probes `https://zenmux.ai/api/v1/models` (the `/chat/completions`-stripped `<baseUrl>/models` candidate) and serves the live list, falling back to the local catalog only when the upstream fetch fails — import never breaks. ([#4202](https://github.com/diegosouzapw/OmniRoute/issues/4202) — thanks @mikmaneggahommie)
|
||||
- **fix(providers): Vercel AI Gateway "import models" now loads the live catalog instead of nothing** — adding a Vercel AI Gateway key worked, but clicking **import** on the models page loaded nothing usable (manually adding the same models worked). Same class as #4202 (zenmux) / #3976 (llm7/byteplus): `vercel-ai-gateway` carries a real `baseUrl` (`https://ai-gateway.vercel.sh/v1/chat/completions`, format `openai`) in the registry, but was not classified by any live-fetch branch of the model-import route (not `openai-compatible-*`, not self-hosted, not in `NAMED_OPENAI_STYLE_PROVIDERS`), so the route never probed the upstream `/models` and fell through to the registry's tiny 5-entry hardcoded `models[]`. Added `vercel-ai-gateway` to `NAMED_OPENAI_STYLE_PROVIDERS`, so the route probes `https://ai-gateway.vercel.sh/v1/models` (the `/chat/completions`-stripped `<baseUrl>/models` candidate) and serves the live list, falling back to the local catalog only when the upstream fetch fails — import never breaks. ([#4249](https://github.com/diegosouzapw/OmniRoute/issues/4249) — thanks @FerLuisxd)
|
||||
- **fix(sse): clear error when the request queue drops a job (no more fake-upstream "This job timed out after Nms")** — under concurrent load, requests that exceed the per-connection rate-limit queue budget (`resilienceSettings.requestQueue.maxWaitMs`) were dropped by Bottleneck with its raw `This job timed out after <maxWaitMs> ms.` message. That string is indistinguishable from an upstream gateway timeout, so the 502 body and call-log `last_error` looked like a provider outage across unrelated providers (TI:0\|TO:0) — an operator spent ~3h misdiagnosing local queue saturation as upstream failures. `withRateLimit` now rewrites that specific Bottleneck error into a clear, OmniRoute-owned message that names the knob (`requestQueue.maxWaitMs`, tunable in Settings → Resilience), explicitly disclaims an upstream timeout, preserves the original as `cause`, and tags `code: "RATE_LIMIT_QUEUE_TIMEOUT"`. Behavior is unchanged — the job is still dropped so combo falls back to the next target. ([#4165](https://github.com/diegosouzapw/OmniRoute/issues/4165) — thanks @KooshaPari)
|
||||
- **fix(api): advertise the built-in `auto/*` combos in `/v1/models`** — OmniRoute ships a zero-setup `auto/*` catalog (`auto/best-coding`, `auto/pro-reasoning`, …, 16 variants) that the dashboard advertises and that resolve on demand, but the `/v1/models` listing only emitted persisted DB combos + provider models. Clients that build their model picker from `/v1/models` (e.g. Hermes Agent) never saw any `auto/*` option. The catalog now emits every `AUTO_TEMPLATE_VARIANTS` id (as `owned_by: "combo"`) at the top of the list, deduped against persisted combos. (Showing each `auto/*`'s dynamically-selected members is a separate enhancement.) ([#4164](https://github.com/diegosouzapw/OmniRoute/issues/4164) — thanks @MRDGH2821)
|
||||
- **fix(sse): restore MCP / third-party tool names on the native Claude path (MCP dispatch broken in Claude Code)** — since 3.8.27, every MCP tool call routed through OmniRoute to a native Claude OAuth provider failed client-side with `Error: No such tool available: <PascalCaseName>`: tool schemas arrived fine but the streamed `tool_use.name` reached Claude Code in its cloaked form (e.g. `McpN8nMcpSearchWorkflows` instead of the registered `mcp__n8n-mcp__search_workflows`). The native-Claude tool-name cloak stashes its per-request alias→original map as a **non-enumerable** `_toolNameMap` on the request body; the request-inspector capture added in 3.8.27 rebuilds the captured body from its serialized form (`JSON.parse(JSON.stringify(...))`), which drops non-enumerable properties, so `finalBody._toolNameMap` was empty and the response-side un-cloak silently fell back to the static built-in map — never restoring dynamic MCP / snake_case names. Built-in tools (Bash/Read/…) were unaffected (static map); cross-format paths were unaffected (they attach the map enumerably). The provider-request capture now re-attaches the per-request map (kept non-enumerable, so it still never re-serializes upstream) when the captured copy lost it, restoring MCP tool dispatch. ([#4091](https://github.com/diegosouzapw/OmniRoute/issues/4091) — thanks @pedrotecinf, @NakHalal)
|
||||
- **fix(dashboard): Logs auto-refresh self-heals in embedded/proxied hosts that pin or mis-fire visibility** — a follow-up to #4054: the Request Logger still froze auto-refresh on some hosts (reported on 3.8.28 Docker, works on 3.8.24). #4054 made the initial visibility fail-open, but the pause is event-driven — a host that fires a one-shot `visibilitychange` → hidden and then keeps reporting `"hidden"` (or recovers without firing the event again) left the cached visibility flag stuck `false`, so the interval ticked but never polled (only the manual Refresh button worked). The poll tick now also re-checks the **live** `document.visibilityState`, and a **window `focus`** listener re-arms polling (a focused window is a reliable signal the page is actively viewed). A genuinely backgrounded browser tab still pauses (it reports `"hidden"` and never receives focus), preserving the #3109 network-saturation optimization. ([#4133](https://github.com/diegosouzapw/OmniRoute/issues/4133) — thanks @tjengbudi)
|
||||
- **fix(capabilities): unify vision model-id detection into one shared source** — three code paths kept independent, drifting vision-model lists, so the same model id could get up to three different verdicts. Two concrete bugs: lite compression's gate was missing pixtral / llava / qwen-vl / glm-4v / kimi-vl / mistral-medium-3, so it **stripped images for those real vision models and blinded them** (same class as #4071 / #4012); and the `/v1/models` list was too broad, flagging text models (`gemma`, bare `kimi` like `kimi-k2`) as vision. All three (`modelCapabilities` routing fallback, `/v1/models` listing, lite image-strip gate) now delegate to a single conservative source `src/shared/constants/visionModels.ts`, which also restores `glm-4v` / `gemini-3` coverage and keeps the #3328 MiniMax M3 carve-out. ([#4072](https://github.com/diegosouzapw/OmniRoute/issues/4072) — thanks @diego-anselmo)
|
||||
- **fix(sse): surface mid-stream Gemini errors instead of returning a truncated 200** — when an upstream Gemini SSE stream emitted some partial content and then a JSON error object (`{"error":{"code":503,"message":"…high demand…","status":"UNAVAILABLE"}}`) instead of a `candidates` payload, OmniRoute silently dropped it: the gemini→openai translator's no-candidate branch only handled `promptFeedback` (content-filter) and returned `null` for anything else, so the stream simply ended and the client got HTTP 200 with a truncated body and `finish_reason: "stop"` — masking the failure and skipping combo fallback. `geminiToOpenAIResponse` now detects an `error` object (optionally wrapped in `response`), records it as `state.upstreamError` (preserving the real status — 503/`UNAVAILABLE`, or 429 for `RESOURCE_EXHAUSTED`), and lets `stream.ts` error the stream out through the existing `onFailure`/`buildErrorBody`/`controller.error` path — the same mechanism the openai-responses translator already uses. ([#4177](https://github.com/diegosouzapw/OmniRoute/issues/4177) — thanks @hartmark)
|
||||
- **fix(capabilities): resolve models.dev-synced vision metadata for Mistral `-latest` aliases** — root cause behind the #4071 heuristic: `getResolvedModelCapabilities("mistral/pixtral-12b-latest").supportsVision` resolved `null` (vision came only from the #4071 model-id heuristic, with `attachment` still `null`) even though models.dev exposes the model as multimodal. Confirmed against the live models.dev API: it catalogs Pixtral 12B under the **short** id `pixtral-12b` (with `attachment: true`, `modalities.input: ["text","image"]`), while requests use the Mistral API alias `pixtral-12b-latest`. The synced lookup tried the exact / raw / static-spec-canonical ids — all of which miss the short form — so it fell through to the heuristic. `getSyncedCapabilityForResolved` now adds a last-resort fallback that retries with a trailing `-latest` stripped, so synced metadata (`attachment` / image modalities) wins for these aliases; models whose `-latest` id is stored verbatim (e.g. `pixtral-large-latest`) keep resolving directly. Note: the models.dev sync is currently manual-only (Settings → models.dev) with no scheduled refresh, so a fresh instance still relies on the #4071 heuristic until that sync runs — a periodic-refresh cadence is left as a separate follow-up. ([#4073](https://github.com/diegosouzapw/OmniRoute/issues/4073) — thanks @diego-anselmo)
|
||||
- **fix(sse): map Xiaomi MiMo reasoning control to its native `thinking:{type}` shape** — MiMo (`api.xiaomimimo.com`) controls chain-of-thought **only** via top-level `thinking:{type:"enabled"|"disabled"}` and does not understand OpenAI's `reasoning_effort`/`reasoning`, while its request validator is strict (`400 Param Incorrect`). OmniRoute's OpenAI path carried reasoning intent as `reasoning_effort`, and the claude→openai translator can leave a Claude-shaped `thinking:{type, budget_tokens}` — so the client's on/off choice was silently dropped and `budget_tokens`/`reasoning_effort` rode along as extra params the validator can reject. New `open-sse/services/mimoThinking.ts::normalizeMimoThinking` (wired in `chatCore` for `provider==="xiaomi-mimo"`) reduces any thinking object to just `{type}` (`disabled` stays; `enabled`/`adaptive`/other → `enabled`) and drops `reasoning_effort`/`reasoning`. It deliberately does **not** synthesize thinking from a bare `reasoning_effort` — `mimo-v2-omni` is non-thinking, so that could turn a silently-ignored param into a hard error. ([#4224](https://github.com/diegosouzapw/OmniRoute/pull/4224))
|
||||
- **fix(capabilities): Xiaomi MiMo `*-pro` chat models are text-only (no vision)** — only `mimo-v2.5` and `mimo-v2-omni` accept images per Xiaomi's docs; `mimo-v2.5-pro`/`mimo-v2-pro` are text-only, but `modelSpecs` marked them vision-capable and models.dev mislabels them ([hermes-agent#18884](https://github.com/NousResearch/hermes-agent/issues/18884)). Since `resolveVisionCapability` lets a synced `attachment:true` win first, an image request could be routed to a blind model (the #4071 failure mode). Corrected the specs **and** added a hard override in `resolveVisionCapability` (checked before the synced branch, anchored so `mimo-v2.5-pro` never matches the multimodal `mimo-v2.5`) that beats the wrong synced attachment. Also registered the missing native `mimo-v2-pro` chat model and the missing `mimo-v2-tts` speech model. ([#4224](https://github.com/diegosouzapw/OmniRoute/pull/4224))
|
||||
- **fix(sse): Claude Opus 4.7+/Fable 5 use adaptive thinking only (no more manual-budget 400s)** — Opus 4.7 and later (Opus 4.7/4.8, Fable 5) removed manual extended thinking: `thinking.type:"enabled"` or **any** `thinking.budget_tokens` now returns `400` ("Any request that tries to set a fixed thinking budget gets a 400" — Anthropic migration guide). Reasoning is adaptive-only, steered by `output_config.effort`. OmniRoute's OpenAI→Claude translator mapped `reasoning_effort` low/medium/high to a manual `thinking:{type:"enabled", budget_tokens}`, so those requests hard-400'd on the most-used provider (and a Claude-native passthrough client sending the legacy shape did too). A new `adaptiveThinkingOnly` model flag now drives two fixes: the translator maps `reasoning_effort` of **every** level to `{type:"adaptive"}` + `output_config.effort` (preserving the requested level, never a budget) for these models, and a `normalizeClaudeAdaptiveThinking` catch-all at the existing post-translation thinking-normalization chokepoint collapses any residual manual thinking (passthrough legacy shape, per-model defaults) to `{type:"adaptive"}`, keyed on the resolved upstream model so it covers every routing mode. Pre-4.7 models (Opus 4.6/4.5, Sonnet, Haiku) keep manual budgets unchanged. ([#4230](https://github.com/diegosouzapw/OmniRoute/pull/4230))
|
||||
- **fix(providers): strip non-default temperature/top_p/top_k for Claude Opus 4.7+/Fable 5 (fixed sampling → no 400)** — Opus 4.7 and later reject non-default `temperature`/`top_p`/`top_k` with a `400` (sampling is fixed; reasoning moved to `output_config.effort`). The translator forwarded client-supplied `temperature`/`top_p` unconditionally and the Claude registry models carried no `unsupportedParams`, so a plain OpenAI-format request with `temperature: 0.7` to `claude-opus-4-8` hard-400'd. Added `unsupportedParams: ["temperature","top_p","top_k"]` to the Opus 4.7+/Fable 5 ids in both the `claude` (dashed `claude-opus-4-8`) and `anthropic` (dotted `claude-opus-4.7`) registries, so they're stripped at the existing `getUnsupportedParams` dispatch chokepoint. Pre-4.7 Claude models still accept sampling params. ([#4230](https://github.com/diegosouzapw/OmniRoute/pull/4230))
|
||||
- **fix(providers): conditionally strip temperature/top_p for GPT-5 reasoning on the `openai` Chat Completions path (no 400 when an effort is active)** — GPT-5 reasoning models reject non-default `temperature`/`top_p` with a `400` whenever a reasoning effort is active, yet accept them again under `reasoning_effort:"none"` (the GPT-5.1+ default, i.e. non-reasoning mode). On the `openai` provider only `o3` carried `REASONING_UNSUPPORTED`; `gpt-5.5`/`gpt-5.4`/`gpt-5.4-mini`/`gpt-5.4-nano` carried no sampling guard, so a `temperature` + active-effort request hard-400'd. A static `unsupportedParams` list can't express the `none`-mode carve-out (it would over-strip the legitimate case), so the new `gpt5SamplingGuard` drops `temperature`/`top_p` only when the resolved effort is active — wired at the existing `getUnsupportedParams` chokepoint and scoped to the `openai` Chat Completions surface (the `codex` Responses path is already covered by the CodexExecutor allowlist; other providers are untouched). ([#4245](https://github.com/diegosouzapw/OmniRoute/pull/4245))
|
||||
- **fix(codex): stop silently dropping GPT-5 output verbosity (`verbosity` / `text.verbosity`)** — the GPT-5 series added an output-verbosity control: `verbosity` (low/medium/high) on Chat Completions, nested as `text.verbosity` on the Responses API. The CodexExecutor gates translated requests through an allowlist that had no `text` entry, so for the `codex` provider the hint was dropped before reaching upstream (the `openai` Chat path already forwarded it). `normalizeCodexVerbosity` now folds whichever shape arrived into a single validated `text:{verbosity}` before the allowlist (which now permits `text`), and the OpenAI Chat↔Responses request translators map `verbosity` across formats so the hint survives a format crossing for non-codex Responses backends too. Invalid/absent verbosity collapses to no `text` (status quo). ([#4245](https://github.com/diegosouzapw/OmniRoute/pull/4245))
|
||||
- **fix(sse): map `reasoning_effort` to DeepSeek V4's native `{high, max}` vocabulary** — DeepSeek V4 only understands `high`/`max` reasoning levels, so other `reasoning_effort` values are mapped onto its native vocabulary instead of being rejected. ([#4219](https://github.com/diegosouzapw/OmniRoute/pull/4219))
|
||||
- **fix(glm): default `max_tokens` and an extended timeout for GLM-5.2+ thinking** — GLM-5.2+ thinking responses are slow and need headroom, so OmniRoute now sets a sensible default `max_tokens` and a longer timeout for them. ([#4255](https://github.com/diegosouzapw/OmniRoute/pull/4255) — thanks @dhaern)
|
||||
- **fix(antigravity): default `includeThoughts` for modern Gemini models** — modern Gemini models on the Antigravity path now default to including thoughts so reasoning isn't silently dropped. ([#4180](https://github.com/diegosouzapw/OmniRoute/pull/4180) — thanks @dhaern)
|
||||
- **fix(provider-registry): add correct `contextLength` to theoldllm models** — fills in accurate context-window sizes for theoldllm's models. ([#4184](https://github.com/diegosouzapw/OmniRoute/pull/4184) — thanks @herjarsa)
|
||||
- **fix(models): expose combo model token limits** — `/v1/models` now reports token limits for combo models. ([#4189](https://github.com/diegosouzapw/OmniRoute/pull/4189) — thanks @megamen32)
|
||||
- **fix(combo): keep the passthrough quota fallback scoped** — prevents the passthrough quota fallback from leaking across unrelated targets. ([#4194](https://github.com/diegosouzapw/OmniRoute/pull/4194) — thanks @Svetznaniy33)
|
||||
- **fix(combo): opt proactive-fallback compression into the TV1 bail-out (no silent target drop)** — proactive-fallback compression now participates in the TV1 bail-out so a target is never silently dropped. ([#4228](https://github.com/diegosouzapw/OmniRoute/pull/4228))
|
||||
- **fix(compression): show engine preview output** — the Compression Studio preview now renders the engine's output. ([#4128](https://github.com/diegosouzapw/OmniRoute/pull/4128) — thanks @megamen32)
|
||||
- **fix(compression): harden engines against I/O failures and misconfig (F5.3)** — compression engines degrade gracefully on I/O errors and bad configuration instead of throwing. ([#4198](https://github.com/diegosouzapw/OmniRoute/pull/4198))
|
||||
- **fix(compression): harden RTK raw-output redaction + ReDoS guard for custom filters (F5.3)** — broadens RTK raw-output redaction and adds a ReDoS guard for user-supplied filter patterns. ([#4203](https://github.com/diegosouzapw/OmniRoute/pull/4203))
|
||||
- **fix(compression): bound `mcpAccessibility` `maxTextChars` on the live read path** — the live read path now clamps `maxTextChars` so a small value can't make tools disappear. ([#4206](https://github.com/diegosouzapw/OmniRoute/pull/4206))
|
||||
- **fix(dashboard): data tables paint an opaque surface so the grid doesn't bleed through** — data tables now render on an opaque surface, fixing the grid wallpaper showing through. ([#4233](https://github.com/diegosouzapw/OmniRoute/pull/4233))
|
||||
- **fix(dashboard): make the provider card hover visible (was ~1% opacity)** — the provider-card hover state was effectively invisible; it now has a visible surface. ([#4214](https://github.com/diegosouzapw/OmniRoute/pull/4214))
|
||||
- **fix(vscode): sanitize implicit editor context** — redacts sensitive filenames/keywords from the implicit VS Code editor context before it's sent upstream. ([#4124](https://github.com/diegosouzapw/OmniRoute/pull/4124) — thanks @zhiru)
|
||||
- **fix(build): raise the Node heap for the local `next build` to stop OOM/stall** — bumps the build-time heap so the local production build no longer OOMs or stalls. ([#4171](https://github.com/diegosouzapw/OmniRoute/pull/4171))
|
||||
- **fix(mitm): TPROXY OUTPUT-based recipe for local traffic (validated e2e on VPS)** — switches the TPROXY rules to an OUTPUT-chain recipe so locally-originated traffic is captured; validated end-to-end on the VPS. ([#4156](https://github.com/diegosouzapw/OmniRoute/pull/4156))
|
||||
- **fix(mitm): forward anti-loop — put the bypass-marked socket on the Agent (decrypt 4d)** — places the bypass-marked socket on the HTTP Agent so OmniRoute's own forwarded traffic never re-enters the capture loop; VPS-validated. ([#4229](https://github.com/diegosouzapw/OmniRoute/pull/4229))
|
||||
- **fix(free-tiers): retire dead-tier `hasFree`, round the headline to ~1.6B, regenerate the per-provider table** — drops dead free tiers from the headline math and regenerates the per-provider free-tier table. ([#4142](https://github.com/diegosouzapw/OmniRoute/pull/4142))
|
||||
- **fix(free-tiers): retire 4 re-verified-dead free tiers, flag iflytek/sparkdesk ToS, clarify monsterapi one-time** — removes four confirmed-dead free tiers and annotates ToS/one-time caveats. ([#4152](https://github.com/diegosouzapw/OmniRoute/pull/4152))
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- **test(sse): guard the Antigravity `_toolNameMap` cloak map through the request-capture round-trip** — follow-up to #4091: the generic capture fix in `createPreparedRequestLogger().body()` (#4153) re-attaches the non-enumerable `_toolNameMap` that the request-inspector drops when it rebuilds the upstream body via `JSON.parse(JSON.stringify(...))`, but the only regression test covered the native-Claude OAuth cloak (PascalCase aliases). The Antigravity cloak differs — `cloakAntigravityToolPayload` suffixes custom tools with `_ide` (`workspace_read` → `workspace_read_ide`), leaves native tools untouched, and returns the reverse map separately — so a refactor of `providerRequestLogging.ts` or the executor could silently re-break Antigravity tool dispatch without tripping the Claude test. Adds a dedicated regression test driving the real `cloakAntigravityToolPayload` through the capture round-trip and asserting the `_ide` reverse map survives, stays non-enumerable (never re-serializes upstream), and that all-native traffic produces no spurious map (verified failing with the #4153 re-attach removed). No production change. ([#4181](https://github.com/diegosouzapw/OmniRoute/issues/4181) — thanks @hertznsk)
|
||||
- **test(chatcore): dedicated unit tests for 6 leaves + wire into stryker mutate (QG v2 Fase 9 T5 Fase 3)** — adds focused unit tests for 6 chatCore leaf helpers and enrolls them in mutation testing. ([#4218](https://github.com/diegosouzapw/OmniRoute/pull/4218))
|
||||
- **test(chatcore): telemetry / memory-skills / semantic-cache tests + wire 2 into stryker (QG v2 Fase 9 T5 Fase 3)** — new tests for the telemetry, memory-skills and semantic-cache leaves, two of which are added to the mutation set. ([#4222](https://github.com/diegosouzapw/OmniRoute/pull/4222))
|
||||
- **test+ci(chatcore): semanticCache HIT-path fixture (15/15 mutate) + 350min budget headroom** — closes the semantic-cache HIT path to a full 15/15 mutation score and gives the nightly auth/accountFallback batches more budget headroom. ([#4225](https://github.com/diegosouzapw/OmniRoute/pull/4225))
|
||||
- **test(compression): close F5.1 coverage gaps (replay reducer, live accumulator, StatusDot)** — fills the remaining F5.1 compression coverage gaps. ([#4192](https://github.com/diegosouzapw/OmniRoute/pull/4192))
|
||||
- **test(db,sse): de-flake db-backup + chatcore streaming timing assertions** — stabilizes two timing-sensitive tests (fire-and-forget backup completion + a streaming race). ([#4132](https://github.com/diegosouzapw/OmniRoute/pull/4132))
|
||||
- **test: align stale integration tests surfaced post-v3.8.28 on main** — realigns integration tests that drifted after the v3.8.28 merge. ([#4129](https://github.com/diegosouzapw/OmniRoute/pull/4129))
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **refactor(sse): split chatCore.ts pure helpers into chatCore/ modules (−561 LOC)** — extracts pure helpers out of the chatCore god-file into dedicated modules (Onda 3). ([#4159](https://github.com/diegosouzapw/OmniRoute/pull/4159))
|
||||
- **refactor(chatcore): extract passthrough/header/telemetry helpers (QG v2 Fase 9 T5 C2-C3-C5)** — further chatCore decomposition. ([#4188](https://github.com/diegosouzapw/OmniRoute/pull/4188))
|
||||
- **refactor(chatcore): extract combo/proxy context cache + semaphore helpers (QG v2 Fase 9 T5 C6-C7)** — continues the chatCore split. ([#4193](https://github.com/diegosouzapw/OmniRoute/pull/4193))
|
||||
- **refactor(combo): god-file split pilot — types + validateQuality + predicates (QG v2 Fase 9 T5 D1-D3)** — first slice of the combo.ts decomposition. ([#4162](https://github.com/diegosouzapw/OmniRoute/pull/4162))
|
||||
- **refactor(combo): god-file split part 2 — shadow + sorters + structure (QG v2 Fase 9 T5 D4-D6)** — continues the combo.ts split. ([#4175](https://github.com/diegosouzapw/OmniRoute/pull/4175))
|
||||
- **refactor(combo): god-file split part 3 — auto strategy (QG v2 Fase 9 T5 D8)** — extracts the auto strategy from combo.ts. ([#4186](https://github.com/diegosouzapw/OmniRoute/pull/4186))
|
||||
- **refactor(combo): extract round-robin sticky state to `combo/rrState.ts` (D7a)** — moves round-robin sticky state into its own module. ([#4196](https://github.com/diegosouzapw/OmniRoute/pull/4196))
|
||||
- **refactor(combo): extract the reset-aware quota block to `combo/quotaStrategies.ts` (D7b)** — moves the reset-aware quota strategies into their own module. ([#4204](https://github.com/diegosouzapw/OmniRoute/pull/4204))
|
||||
- **refactor(compression): remove vestigial SLM seam + dead deprecated alias** — drops dead compression code. ([#4253](https://github.com/diegosouzapw/OmniRoute/pull/4253))
|
||||
- **chore(compression): remove vestigial reconstructCcr/SessionDedup round-trip helpers** — removes unused round-trip helpers. ([#4226](https://github.com/diegosouzapw/OmniRoute/pull/4226))
|
||||
- **chore(compression): remove dead exports + fix stale llmlingua docs** — prunes dead exports and corrects stale LLMLingua docs. ([#4223](https://github.com/diegosouzapw/OmniRoute/pull/4223))
|
||||
- **chore(build): build + ship the TPROXY native addon in the standalone (prebuilds 4e)** — bundles the native TPROXY addon prebuilds into the standalone build. ([#4236](https://github.com/diegosouzapw/OmniRoute/pull/4236))
|
||||
- **chore(ci): add quota + 6 covered chatCore leaves to stryker mutate (QG v2 Fase 9 T5 Fase 3 follow-up)** — enrolls more covered leaves into mutation testing. ([#4209](https://github.com/diegosouzapw/OmniRoute/pull/4209))
|
||||
- **chore(ci): re-add 8 combo split leaves to stryker mutate + expand nightly batch-matrix 3→5 (QG v2 Fase 9 T5 Fase 3)** — restores mutation coverage for the split combo leaves and widens the nightly matrix. ([#4205](https://github.com/diegosouzapw/OmniRoute/pull/4205))
|
||||
- **chore(quality): close v3.8.28 cycle gate drift (re-baseline + nightly-mutation scope)** — reconciles quality-gate baselines after the v3.8.28 cycle. ([#4135](https://github.com/diegosouzapw/OmniRoute/pull/4135))
|
||||
- **ci(mutation): split nightly into 3 parallel batches to fit the 180min budget (QG v2 Fase 9 T0)** — parallelizes the nightly mutation run. ([#4150](https://github.com/diegosouzapw/OmniRoute/pull/4150))
|
||||
- **ci(mutation): restore cold-seed timeout headroom (a/b lost in #4225 squash) + extend to c/d/g/h** — restores and extends per-batch cold-seed timeouts. ([#4258](https://github.com/diegosouzapw/OmniRoute/pull/4258))
|
||||
- **ci(docs): harden the fabricated-docs checker + enforce `--strict` (QG v2 Fase 9 T9)** — tightens the anti-hallucination docs checker. ([#4149](https://github.com/diegosouzapw/OmniRoute/pull/4149))
|
||||
- **ci: derive the oasdiff base-ref from the package version + flag the mutation-toolchain regression** — fixes the OpenAPI-diff base-ref and surfaces a mutation-toolchain regression. ([#4134](https://github.com/diegosouzapw/OmniRoute/pull/4134))
|
||||
- **docs(ci): correct the mutation-gate note (no regression — `stryker -c` is `--concurrency`); record Task 12 GO** — corrects a misread of the stryker flag and records the spike GO. ([#4138](https://github.com/diegosouzapw/OmniRoute/pull/4138))
|
||||
- **docs(api): document the `/api/v1/ws` chat WebSocket endpoint in openapi.yaml** — adds the WebSocket chat endpoint to the OpenAPI spec. ([#4215](https://github.com/diegosouzapw/OmniRoute/pull/4215))
|
||||
- **docs(readme): expand Acknowledgments into a themed, star-counted credits hall** — reworks the README acknowledgments section. ([#4195](https://github.com/diegosouzapw/OmniRoute/pull/4195))
|
||||
- **style(dashboard): shrink the identity grid cell 46px → 32px (~30% smaller)** — tightens the identity grid density. ([#4143](https://github.com/diegosouzapw/OmniRoute/pull/4143))
|
||||
|
||||
### 🔧 Dependencies
|
||||
|
||||
- **deps: bump the production group with 5 updates** — routine production-dependency bumps. ([#4121](https://github.com/diegosouzapw/OmniRoute/pull/4121))
|
||||
- **chore(deps): bump github/codeql-action from 3 to 4** — CI action update. ([#4120](https://github.com/diegosouzapw/OmniRoute/pull/4120))
|
||||
- **chore(deps): bump actions/setup-python from 5 to 6** — CI action update. ([#4119](https://github.com/diegosouzapw/OmniRoute/pull/4119))
|
||||
|
||||
---
|
||||
|
||||
## [3.8.28] — 2026-06-17
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(providers): add OrcaRouter (OpenAI-compatible routing gateway)** — OrcaRouter is now registered as an API-key provider. Its adaptive router is exposed as `orcarouter/auto` (smart routing across 150+ upstream models), alongside a curated flagship set (GPT-5.5, Gemini 3.5 Flash, Claude Opus 4.8, Grok 4.3, DeepSeek V4 Pro, MiniMax M2.7, Qwen3.7 Max). `passthroughModels` is enabled so any OrcaRouter model id works. OpenAI-compatible endpoint (`https://api.orcarouter.ai/v1`), Bearer (`sk-orca-…`) auth — no custom executor or translator required. ([#4070](https://github.com/diegosouzapw/OmniRoute/pull/4070) — thanks @jinhaosong-source)
|
||||
- **feat(providers): add Wafer AI (Anthropic-compatible, Bearer auth)** — Wafer AI is now a built-in provider speaking the Anthropic Messages format with Bearer authentication, registered with its model catalog so it works out of the box. ([#4098](https://github.com/diegosouzapw/OmniRoute/pull/4098) — thanks @diegosouzapw)
|
||||
- **feat(cli): `omniroute launch` — zero-config Claude Code launcher** — a new CLI subcommand that boots OmniRoute (if not already running) and launches Claude Code pre-wired to it, with no manual env/settings editing. ([#4097](https://github.com/diegosouzapw/OmniRoute/pull/4097) — thanks @diegosouzapw)
|
||||
- **feat(api): exact offline token counting for the `count_tokens` fallback via tiktoken** — the local `count_tokens` fallback now uses a real tiktoken (BPE) tokenizer for exact offline counts instead of a heuristic estimate, so token budgeting is accurate even when no upstream count endpoint is reachable. ([#4087](https://github.com/diegosouzapw/OmniRoute/pull/4087) — thanks @diegosouzapw)
|
||||
- **feat(sse): Claude Code quota-probe bypass + command meta-request helpers** — ported from free-claude-code: OmniRoute now recognizes Claude Code's quota-probe and command meta-requests and serves them locally instead of burning an upstream call, reducing wasted quota during CLI sessions. ([#4083](https://github.com/diegosouzapw/OmniRoute/pull/4083) — thanks @diegosouzapw)
|
||||
- **feat(sse): generic 400 field-downgrade retry + Groq field stripping** — when an upstream rejects a request with `400` because of an unsupported field, OmniRoute now strips the offending field and retries (a generic downgrade path), with Groq-specific field stripping wired in. Aligned with the existing `context_management` retry handling. ([#4096](https://github.com/diegosouzapw/OmniRoute/pull/4096) — thanks @diegosouzapw)
|
||||
- **feat(sse): delegated Anthropic Context Editing — relay coverage + 400-fallback** — extends the Claude server-side Context Editing delegation (#4021) with broader relay coverage and a `400`-fallback so a request the upstream rejects for the context-management beta degrades gracefully instead of failing. ([#4065](https://github.com/diegosouzapw/OmniRoute/pull/4065) — thanks @diegosouzapw)
|
||||
- **feat(compression): record per-engine Context Editing telemetry** — the compression pipeline now records a `context-editing` engine entry so the dashboard attributes server-side Context Editing savings alongside the local compression engines. ([#4062](https://github.com/diegosouzapw/OmniRoute/pull/4062) — thanks @diegosouzapw)
|
||||
- **feat(compression): RTK learn/discover (sample source + API + UI)** — the rule-based RTK compression engine gains a learn/discover workflow: sample a source, surface candidate rules through a new API, and review/apply them in the dashboard. ([#4088](https://github.com/diegosouzapw/OmniRoute/pull/4088) — thanks @diegosouzapw)
|
||||
- **feat(dashboard): 2026-06-17 free-tier refresh — honest catalog, uncapped + boost tiers, Layout A budget table** — the free-tier page was refreshed with an honest, deep-researched catalog (pooled/realistic figures rather than inflated 24/7 RPM math), new `recurring-uncapped` and boost tiers, new providers, and a KPI + budget table (Layout A). ([#4089](https://github.com/diegosouzapw/OmniRoute/pull/4089) — thanks @diegosouzapw)
|
||||
- **feat(dashboard): Combo Studio connection-cooldown badge (U1b Slice 2)** — the Combo Live cascade now surfaces each connection's cooldown state as a badge, complementing the circuit-breaker badge shipped in 3.8.27. ([#4068](https://github.com/diegosouzapw/OmniRoute/pull/4068) — thanks @diegosouzapw)
|
||||
- **feat(mitm): attribute intercepted requests to the originating process (Gap 1)** — the Traffic Inspector now resolves each intercepted connection back to the originating local process (via `/proc`), so captured traffic can be attributed to the app that produced it. (ProxyBridge-inspired hardening.) ([#4085](https://github.com/diegosouzapw/OmniRoute/pull/4085) — thanks @diegosouzapw)
|
||||
- **feat(mitm): capture-pipeline self-test route (Gap 12)** — a diagnostic route that exercises the MITM capture pipeline end-to-end so operators can confirm interception is working without crafting a real upstream call. ([#4093](https://github.com/diegosouzapw/OmniRoute/pull/4093) — thanks @diegosouzapw)
|
||||
- **feat(mitm): loop-guard self-check + verbosity control in `server.cjs` (Gaps 14+15)** — the MITM proxy gains a self-referential loop guard (so it never proxies its own traffic into an infinite loop) and a `MITM_VERBOSE` routing-decision log level. ([#4101](https://github.com/diegosouzapw/OmniRoute/pull/4101) — thanks @diegosouzapw)
|
||||
- **feat(agent-bridge): portable JSON import/export of config (Gap 4)** — the Agent Bridge / MITM configuration can now be exported to and imported from a portable JSON file, so a working setup can be backed up or moved between machines. ([#4094](https://github.com/diegosouzapw/OmniRoute/pull/4094) — thanks @diegosouzapw)
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(ws): start the LiveWS sidecar with `cwd` at the package root (global/systemd installs)** — the standalone LiveWS launcher (`scripts/start-ws-server.mjs`) re-spawns itself with `node --import tsx <self>` but did not set `cwd`. When the WebSocket sidecar was launched from outside the package directory — a global npm/homebrew install, or a `systemd`/`launchd` unit started from `$HOME` — Node could not resolve the `tsx` package (`ERR_MODULE_NOT_FOUND: Cannot find package 'tsx'`), and even from the package directory `tsx` could not resolve the tsconfig `@/*` path aliases (e.g. `@/types/databaseSettings`), so the sidecar never booted. The spawn now pins `cwd` to the package root (the directory above `scripts/`, where `package.json` + `tsconfig.json` live), which resolves both `tsx` discovery and the `@/*` aliases regardless of launch directory. ([#4055](https://github.com/diegosouzapw/OmniRoute/issues/4055) — thanks @Rahulsharma0810)
|
||||
- **fix(dashboard): Logs page auto-refresh now works in embedded/proxied dashboards** — the Request Logger gated each auto-refresh tick on a static `document.visibilityState === "visible"` read. Hosts that report a permanent non-`"visible"` state without ever firing a `visibilitychange` event (Docker dashboard wrappers, embedded webviews) froze auto-refresh entirely — only the manual Refresh button worked, a regression from 3.8.24's unconditional polling. The pause is now event-driven and fail-open: polling starts enabled and only pauses after a real `visibilitychange` → hidden transition (still preserving the backgrounded-tab optimization for normal browser tabs). ([#4054](https://github.com/diegosouzapw/OmniRoute/issues/4054) — thanks @tjengbudi)
|
||||
- **fix(docker): raise the build-stage Node heap to stop the production-build OOM** — the Docker `builder` stage ran `npm run build` with V8's default heap ceiling (~2 GB). After #4052 forced the heavier webpack engine (Turbopack panics on this Next.js version), the production optimization pass exceeded that ceiling and the build died with `FATAL ERROR: … JavaScript heap out of memory` at `[builder] npm run build`. The builder stage now sets `NODE_OPTIONS=--max-old-space-size` (default 4096 MB, overridable via `--build-arg OMNIROUTE_BUILD_MEMORY_MB=…`) before the build; the value propagates to the spawned `next build`. Build-only — the runtime heap (`OMNIROUTE_MEMORY_MB` on the runner stage) is unchanged. ([#4076](https://github.com/diegosouzapw/OmniRoute/issues/4076) — thanks @kamenkadmitry)
|
||||
- **fix(dashboard): "Update Available" banner reappears reliably across Docker/npm/desktop installs** — the home-page banner is gated on `GET /api/system/version`'s `updateAvailable`, which derived the latest version ONLY from `npm info omniroute version --json` via the `npm` CLI binary. When that binary is absent from the runtime PATH (Docker/desktop/locked-down installs) or the registry is unreachable, the call returned `null` → `updateAvailable=false` → the banner silently never rendered even when a newer release existed. The route now resolves the latest version through `resolveLatestVersion()`: the fast `npm` CLI path first, then an npm-binary-free fallback over the registry HTTP API (`registry.npmjs.org/omniroute/latest`), and a logged warning instead of silent degradation when both fail. Version comparison was also hardened to tolerate `v`-prefixed and pre-release version strings. ([#4100](https://github.com/diegosouzapw/OmniRoute/issues/4100))
|
||||
- **fix(sse): route image requests only to confirmed-vision combo targets** — a combo could route an image-bearing request to a member that doesn't actually support vision. Routing now requires `supportsVision === true` (plus a model-id heuristic) before sending images to a target, so multimodal requests land only on members that can handle them. ([#4071](https://github.com/diegosouzapw/OmniRoute/pull/4071) — thanks @diego-anselmo)
|
||||
- **fix(security): injection guard respects the `INJECTION_GUARD_MODE` DB feature flag** — the prompt-injection guard ignored the database feature-flag, so operators couldn't change its mode at runtime; it now reads the flag and honors the configured mode. ([#4077](https://github.com/diegosouzapw/OmniRoute/pull/4077) — thanks @zhiru)
|
||||
- **fix(ws): proxy LAN `/live-ws` upgrades and warn on an unset `JWT_SECRET`** — WebSocket upgrade requests arriving over the LAN proxy path were not forwarded to the LiveWS sidecar; they are now proxied correctly, and the server logs a clear warning when `JWT_SECRET` is unset. ([#4079](https://github.com/diegosouzapw/OmniRoute/pull/4079) — thanks @Rahulsharma0810)
|
||||
- **fix(dev): force webpack in the custom dev server (Turbopack 16.2.x panics)** — the custom dev server now forces the webpack engine because Turbopack panics on this Next.js version, so `npm run dev` boots reliably. ([#4092](https://github.com/diegosouzapw/OmniRoute/pull/4092) — thanks @chirag127)
|
||||
- **fix(auto): resolve built-in `auto/*` catalog combos** — referencing a built-in `auto/*` combo returned a premature `400` because the catalog entry wasn't resolved; the built-in auto catalog is now resolved before validation so those combos work. ([#4058](https://github.com/diegosouzapw/OmniRoute/pull/4058) — thanks @megamen32)
|
||||
- **fix(sse): friendly 413 message for ChatGPT-web payload-too-large** — an oversized ChatGPT-web payload returned an opaque error; it now returns a clear `413` with a human-readable message. ([#4080](https://github.com/diegosouzapw/OmniRoute/pull/4080) — thanks @diegosouzapw)
|
||||
- **fix(ws): warm the SSE auth import on LiveWS startup; relocate the boot test to integration** — the LiveWS sidecar now pre-imports the SSE auth module at startup to avoid a first-request stall, and its boot test was moved to the integration suite. ([#4063](https://github.com/diegosouzapw/OmniRoute/pull/4063) — thanks @diegosouzapw)
|
||||
- **fix(mitm): crash-safe system-state teardown + socket timeouts (ProxyBridge-inspired hardening)** — the MITM proxy could leave the host's system proxy settings applied if it crashed mid-teardown, and long-lived tunnels could leak as half-open sockets. Teardown is now crash-safe (system state is always restored) and proxied sockets get an idle timeout (`MITM_IDLE_TIMEOUT_MS`, default 60s). ([#4084](https://github.com/diegosouzapw/OmniRoute/pull/4084) — thanks @diegosouzapw)
|
||||
- **fix(responses): clear the `/v1/responses` keep-alive timer on cancel/abort** — a cancelled or aborted `/v1/responses` stream left its keep-alive timer running, leaking a timer and burning CPU; the timer is now cleared on cancel/abort. ([#4105](https://github.com/diegosouzapw/OmniRoute/pull/4105) — thanks @artickc)
|
||||
- **fix(usage): reap orphaned pending-request details (unbounded memory leak)** — pending-request detail entries whose request never completed accumulated without bound; they are now reaped, closing a slow memory leak. ([#4107](https://github.com/diegosouzapw/OmniRoute/pull/4107) — thanks @artickc)
|
||||
- **fix(auth): prune expired entries from the login brute-force guard map (unbounded growth)** — the login brute-force guard map grew without bound because expired entries were never removed; expired entries are now pruned. ([#4111](https://github.com/diegosouzapw/OmniRoute/pull/4111) — thanks @artickc)
|
||||
- **fix(logger): hard-cap the error-dedup map to bound memory under unique-message bursts** — a burst of unique error messages could grow the dedup map without limit; it is now hard-capped. ([#4113](https://github.com/diegosouzapw/OmniRoute/pull/4113) — thanks @artickc)
|
||||
- **fix(circuit-breaker): enforce `MAX_REGISTRY_SIZE` (declared but never applied)** — the circuit-breaker registry declared a maximum size that was never enforced, so it could grow unbounded; the cap is now applied. ([#4114](https://github.com/diegosouzapw/OmniRoute/pull/4114) — thanks @artickc)
|
||||
- **fix(webhook): clear the abort timer in `finally` to avoid dangling timers on fetch error** — a webhook dispatch that threw before clearing its abort timer left the timer dangling; it is now cleared in a `finally` block. ([#4115](https://github.com/diegosouzapw/OmniRoute/pull/4115) — thanks @artickc)
|
||||
- **fix(combo): detach the per-target listener from the shared hedge abort signal** — combo hedging attached a per-target listener to a shared abort signal without detaching it, leaking listeners across requests; the listener is now detached. ([#4116](https://github.com/diegosouzapw/OmniRoute/pull/4116) — thanks @artickc)
|
||||
- **fix(timers): unref background interval timers so they don't block clean shutdown** — long-lived background interval timers kept the event loop alive and blocked a clean process exit; they are now `unref`'d. ([#4117](https://github.com/diegosouzapw/OmniRoute/pull/4117) — thanks @artickc)
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- **perf(registry): precompute the model→provider index in `parseModelFromRegistry`** — model→provider lookups now use a precomputed index instead of scanning the registry on every call. ([#4110](https://github.com/diegosouzapw/OmniRoute/pull/4110) — thanks @artickc)
|
||||
- **perf(obfuscation): cache per-word regexes instead of recompiling every request** — the obfuscation pass now caches its per-word regexes rather than recompiling them on each request. ([#4109](https://github.com/diegosouzapw/OmniRoute/pull/4109) — thanks @artickc)
|
||||
- **perf(stream): use `structuredClone` instead of a JSON round-trip for per-chunk reasoning split** — the per-chunk reasoning split now clones with `structuredClone` rather than `JSON.parse(JSON.stringify(...))`. ([#4108](https://github.com/diegosouzapw/OmniRoute/pull/4108) — thanks @artickc)
|
||||
- **perf(gemini): cache the reasoning close-tag regex instead of recompiling per token** — the Gemini reasoning close-tag regex is now compiled once and reused instead of per token. ([#4106](https://github.com/diegosouzapw/OmniRoute/pull/4106) — thanks @artickc)
|
||||
|
||||
### 📝 Maintenance
|
||||
|
||||
- **ci(quality): flip the TIA impacted-unit-tests gate from advisory to blocking (Fase 9)** — the test-impact-analysis gate that runs the unit tests impacted by a diff is now blocking on PRs. ([#4069](https://github.com/diegosouzapw/OmniRoute/pull/4069) — thanks @diegosouzapw)
|
||||
- **ci(quality): dedup the doubly-run `check:docs-sync` + record the validated ROI backlog (Fase 9)** — `check:docs-sync` was running twice in CI; the duplicate was removed and the validated quality-gate ROI backlog recorded. ([#4099](https://github.com/diegosouzapw/OmniRoute/pull/4099) — thanks @diegosouzapw)
|
||||
- **docs(quality-gates): reconcile the gate inventory with `ci.yml` + add the ROI rationalization backlog** — the quality-gate inventory doc was reconciled against the actual CI jobs and a rationalization backlog added. ([#4095](https://github.com/diegosouzapw/OmniRoute/pull/4095) — thanks @diegosouzapw)
|
||||
- **test(infra): isolate `DATA_DIR` per test process; raise Stryker concurrency 1→4** — test processes now get an isolated `DATA_DIR` (no shared-DB cross-talk) and the mutation runner's concurrency was raised. ([#4078](https://github.com/diegosouzapw/OmniRoute/pull/4078) — thanks @diegosouzapw)
|
||||
- **test(dashboard): smoke e2e for the Combo Live Studio page** — adds a Playwright smoke test covering the Combo Live Studio page. ([#4075](https://github.com/diegosouzapw/OmniRoute/pull/4075) — thanks @diegosouzapw)
|
||||
- **docs(compression): document LLMLingua optional deps + on-demand install** — documents the optional LLMLingua dependencies and how they are installed on demand. ([#4061](https://github.com/diegosouzapw/OmniRoute/pull/4061) — thanks @diegosouzapw)
|
||||
- **chore(deps): freeze `@huggingface/transformers` in dependabot (hard-pin)** — the transformers dependency is hard-pinned and frozen in dependabot to protect the VPS-validated LLMLingua + memory-embeddings stack from a breaking major bump. ([#4066](https://github.com/diegosouzapw/OmniRoute/pull/4066) — thanks @diegosouzapw)
|
||||
- **chore(docs): update the Discord invite link to a non-expiring one** — replaces the expiring Discord invite with a permanent link. ([#4067](https://github.com/diegosouzapw/OmniRoute/pull/4067) — thanks @diegosouzapw)
|
||||
- **chore(docs): document the new MITM env vars + reconcile the env-doc contract** — documents `MITM_IDLE_TIMEOUT_MS` and `MITM_VERBOSE` in `.env.example` + `ENVIRONMENT.md`, allowlists the framework-internal `TURBOPACK` and the Claude Code `ANTHROPIC_AUTH_TOKEN`, and relocates/prunes stale provider/guide docs. (thanks @diegosouzapw)
|
||||
|
||||
### 🔧 Dependencies
|
||||
|
||||
- **deps: bump the development group with 10 updates** — routine dependabot dev-dependency bumps. ([#4051](https://github.com/diegosouzapw/OmniRoute/pull/4051))
|
||||
- **deps(electron): bump electron 42.4.0 → 42.4.1** — ([#4049](https://github.com/diegosouzapw/OmniRoute/pull/4049))
|
||||
- **ci(deps): bump `actions/setup-node` 4 → 6** — ([#4048](https://github.com/diegosouzapw/OmniRoute/pull/4048))
|
||||
- **ci(deps): bump `actions/cache` 4.3.0 → 5.0.5** — ([#4047](https://github.com/diegosouzapw/OmniRoute/pull/4047))
|
||||
- **ci(deps): bump `actions/github-script` 7 → 9** — ([#4046](https://github.com/diegosouzapw/OmniRoute/pull/4046))
|
||||
- **ci(deps): bump `ossf/scorecard-action` 2.4.0 → 2.4.3** — ([#4045](https://github.com/diegosouzapw/OmniRoute/pull/4045))
|
||||
- **ci(deps): bump `actions/upload-artifact` 4 → 7** — ([#4044](https://github.com/diegosouzapw/OmniRoute/pull/4044))
|
||||
|
||||
---
|
||||
|
||||
## [3.8.27] — 2026-06-17
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(combos): advertise combo capabilities (multimodal / reasoning / caching) on the import surfaces** — importing a combo package into a client (LobeHub / OpenCode / VS Code, via `/v1/combos` and the VS Code combo catalog) no longer requires manually enabling multimodal/image-input, reasoning, and caching afterwards. `projectCombo` now attaches a registry-derived `capabilities` block, gated conservatively: `multimodal`/`reasoning` are advertised only when **every** concrete model step proves the capability (an unprovable nested combo-ref drops them, since the strategy may route to any member), and `caching` reflects the combo's explicit Context-Cache-Protection setting (no surprise prompt-cache cost). The public `/v1/combos` default projection (#2300) is unchanged unless the caller opts in. ([#3979](https://github.com/diegosouzapw/OmniRoute/issues/3979) — thanks @xenstar)
|
||||
- **feat(sse): delegated Anthropic Context Editing for Claude (`clear_tool_uses`)** — Claude requests can now offload context trimming to Anthropic's server-side context-management API (beta `context-management-2025-06-27`, `clear_tool_uses_20250919`), pruning stale tool-use turns upstream instead of locally. Claude-only by nature (the edit runs server-side); multi-provider context trimming remains the job of the local compression engines. ([#4021](https://github.com/diegosouzapw/OmniRoute/pull/4021) — thanks @diegosouzapw)
|
||||
- **feat(sse): real LLMLingua-2 ONNX compression engine (stable)** — the LLMLingua-2 prompt-compression engine is now a real local ONNX model (TinyBERT default, transformers.js + tfjs), promoted to stable after VPS validation, replacing the previous placeholder. ([#4014](https://github.com/diegosouzapw/OmniRoute/pull/4014) — thanks @diegosouzapw)
|
||||
- **feat(compression): capture per-engine analytics + Lite schema fix** — the compression pipeline now persists a per-engine breakdown for historical analytics so the dashboard can attribute savings to each engine in a stacked pipeline, and a Lite-schema mismatch was corrected. ([#4018](https://github.com/diegosouzapw/OmniRoute/pull/4018) — thanks @diegosouzapw)
|
||||
- **feat(dashboard): real circuit-breaker state in the Combo Live cascade (U1b)** — the Combo Live cascade view now surfaces each provider's real circuit-breaker state (CLOSED / OPEN / HALF_OPEN) as a badge, read live from `/api/monitoring/health`, instead of inferring health from request outcomes. ([#4029](https://github.com/diegosouzapw/OmniRoute/pull/4029) — thanks @diegosouzapw)
|
||||
- **feat(openai): honor a custom base URL in model discovery + complete openai/codex pricing** — OpenAI-format providers configured with a custom base URL now have that URL honored during model discovery (not just inference), and the openai/codex pricing table was completed. Discovery is routed through the SSRF-guarded outbound fetch. ([#4005](https://github.com/diegosouzapw/OmniRoute/pull/4005) — thanks @artickc)
|
||||
- **feat(observability): capture actual upstream provider requests** — the request inspector now records the exact payload sent to the upstream provider (post-translation), so you can see what OmniRoute actually dispatched rather than only the client's original request. ([#3941](https://github.com/diegosouzapw/OmniRoute/pull/3941) — thanks @rdself)
|
||||
- **feat(providers): provider auth visibility controls** — adds controls to show/hide provider auth details in the dashboard so credentials can be revealed only when needed. ([#3953](https://github.com/diegosouzapw/OmniRoute/pull/3953) — thanks @rdself)
|
||||
- **feat(providers): model search filter on the provider dashboard** — the provider dashboard gains a search filter to quickly narrow a provider's model list. ([#3950](https://github.com/diegosouzapw/OmniRoute/pull/3950) — thanks @felipesartori)
|
||||
- **feat(compression): Indonesian caveman rules + language pack** — adds an Indonesian "caveman" rule set and language pack to the rule-based compression engine. ([#3975](https://github.com/diegosouzapw/OmniRoute/pull/3975) — thanks @Veier04)
|
||||
- **feat(dashboard): sidebar group separator toggles** — the dashboard sidebar can now toggle group separators for a cleaner navigation layout. ([#3971](https://github.com/diegosouzapw/OmniRoute/pull/3971) — thanks @rdself)
|
||||
- **feat(api): local `@@om-usage` command for cached per-key usage** — API clients can send a message that is exactly `@@om-usage` to retrieve cached Claude-style usage data locally, without forwarding the prompt to an upstream provider. Gated by a new per-key allowance flag. ([#4034](https://github.com/diegosouzapw/OmniRoute/pull/4034) — thanks @Witroch4)
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(opencode): forward the OpenCode session id to the upstream regardless of how the user named the provider** — the `OpencodeExecutor` forwarded the `x-opencode-session/request/project/client` headers, but the OpenCode CLI only emits those when the configured `providerID` **starts with** `"opencode"`. A user who adds OmniRoute as a custom provider (e.g. `"omniroute"`) makes the CLI send `x-session-affinity` / `X-Session-Id` instead (both carry the same session id), which the executor never read — so the session-metadata forwarding was effectively dead code for the realistic provider-naming case. The opencode-family executor now falls back to `x-session-affinity` / `X-Session-Id` and maps it onto `x-opencode-session` when the client didn't send the header directly, so session continuity to the `opencode.ai` upstream works for any provider name (a direct `x-opencode-session` still wins). Scoped to this executor only — the generic `DefaultExecutor` intentionally does **not** do this, to avoid leaking the client session id to arbitrary third-party upstreams. ([#4022](https://github.com/diegosouzapw/OmniRoute/issues/4022) — thanks @pizzav-xyz)
|
||||
- **fix(guardrails): Vision Bridge no longer drops the image when the describe call fails (Nvidia NIM "Image unavailable")** — the Vision Bridge is enabled by default and engages for any model whose vision capability OmniRoute can't prove from the registry (`supportsVision !== true`, which includes uncatalogued models that resolve to `null`). When the per-image describe call failed (e.g. no vision model configured), it replaced the image with the literal text `[Image N]: (unavailable)` and dropped the original `image_url` — so a genuinely vision-capable upstream (Nvidia NIM) received text only and answered "Image unavailable. Cannot provide description without visual data." A describe failure is no longer destructive: `replaceImageParts` now receives `null` for failed images and **preserves the original image part** so the upstream can still see it (successful describes still replace the image with the text description; `meta.descriptions` observability is unchanged). ([#4012](https://github.com/diegosouzapw/OmniRoute/issues/4012) — thanks @daniij)
|
||||
- **fix(kiro): preserve `finish_reason: "tool_calls"` on the Kiro streaming path** — streaming tool-call requests through the Kiro (Responses API) provider had their terminal `finish_reason` reported as `"stop"` instead of `"tool_calls"`, so agent clients (Hermes) treated the tool-call turn as a finished turn, never ran the tool, and the next request failed with HTTP 400 on the incomplete tool state. `convertKiroToOpenAI`'s terminal `messageStopEvent`/`done` branch hardcoded `finish_reason: "stop"` regardless of whether the stream had emitted `toolUseEvent`s. The translator now records `state.sawToolUse` when a tool-use chunk is emitted and reports `finish_reason: "tool_calls"` on the terminal chunk (and in `state.finishReason`) whenever the stream produced tool calls. The non-streaming path was already correct. ([#3980](https://github.com/diegosouzapw/OmniRoute/issues/3980) — thanks @lordavadon2)
|
||||
- **fix(resilience): respect connection cooldown stored as a numeric epoch** — the router kept dispatching to connections still inside their rate-limit cooldown because `rate_limited_until` (a `TEXT` column) was persisted as a raw epoch number, which SQLite coerced to a string like `"1781696905131.0"` that `new Date(...)` parsed as `NaN`, so the cooling connection was never skipped. The cooldown read predicates now normalize numeric-epoch strings via a shared `cooldownUntilMs()` helper; ISO behavior is unchanged. ([#3995](https://github.com/diegosouzapw/OmniRoute/pull/3995) — thanks @diegosouzapw)
|
||||
- **fix(providers): fetch the live `/models` catalog for LLM7 and BytePlus** — importing an LLM7 or BytePlus key surfaced only a small, outdated hardcoded list because neither provider was classified by any live-fetch branch of the model-import route. Both are now in `NAMED_OPENAI_STYLE_PROVIDERS`, so the route probes `<baseUrl>/models` with the key and serves the live catalog, falling back to the local catalog only when the upstream fetch fails. ([#3996](https://github.com/diegosouzapw/OmniRoute/pull/3996) — thanks @FerLuisxd / @diegosouzapw)
|
||||
- **fix(dashboard): logs auto-refresh reads live visibility, not a stale mount ref** — the Logs page never auto-refreshed when the tab loaded in the background because the auto-refresh interval gated each tick on a visibility ref seeded once at mount; the tick now reads the live `document.visibilityState`, so polling self-heals as soon as the tab is visible while still pausing when genuinely hidden. ([#3997](https://github.com/diegosouzapw/OmniRoute/pull/3997) — thanks @tjengbudi / @diegosouzapw)
|
||||
- **fix(combo): shuffle the strict-random fallback remainder to spread load** — with the `strict-random` strategy a persistently-failing model was retried on essentially every request because only the deck-selected slot 0 was shuffled while the fallback remainder stayed in fixed priority order; the remainder is now shuffled too, so fallback load (and recovery from a failing target) spreads evenly across healthy peers. ([#3998](https://github.com/diegosouzapw/OmniRoute/pull/3998) — thanks @KeNJiKunG / @diegosouzapw)
|
||||
- **fix(claude): forward the client `tool-search-tool-2025-10-19` anthropic-beta on the Claude OAuth path** — with deferred tools active, Claude Code negotiates the `tool-search-tool-2025-10-19` beta, but OmniRoute dropped it on both Claude code paths, so the claude.ai backend rejected every deferred-tool request with `400 Tool reference not found`. A new allowlist-merge (`mergeClientAnthropicBeta`) now unions the client's negotiated beta into the outbound set on both paths, appending only allowlisted client betas (preserving the #3415 fix). ([#3999](https://github.com/diegosouzapw/OmniRoute/pull/3999) — thanks @huohua-dev / @diegosouzapw)
|
||||
- **fix(executor): strip `stream_options` on non-streaming requests (NVIDIA NIM 400)** — clients that send `stream_options: { include_usage: true }` regardless of `stream` (e.g. the OpenAI Python SDK) had it passed through untouched on non-streaming calls, and NVIDIA NIM rejected it with `400 "Stream options can only be defined when stream=True"`. `DefaultExecutor.transformRequest` now strips `stream_options` whenever `stream` is false; the streaming injection path is unchanged. ([#4000](https://github.com/diegosouzapw/OmniRoute/pull/4000) — thanks @andrea-kingautomation / @daniij / @diegosouzapw)
|
||||
- **fix(sse): guard model-less registry entries in `getUnsupportedParams` (mimocode)** — a registry entry without a model map (mimocode) threw when computing unsupported params; the lookup now guards the model-less case so request validation no longer crashes. ([#4015](https://github.com/diegosouzapw/OmniRoute/pull/4015) — thanks @diegosouzapw)
|
||||
- **fix(perplexity-web): parse the schematized `diff_block` stream so answers aren't empty** — Perplexity web streamed its answer as RFC-6902 `diff_block` patches that OmniRoute didn't apply during the `PENDING` phase, so responses came back empty; the parser now applies the patches and materializes the text only on `COMPLETED`. ([#4001](https://github.com/diegosouzapw/OmniRoute/pull/4001) — thanks @artickc)
|
||||
- **fix(default-executor): honor a custom `providerSpecificData.baseUrl` for OpenAI-format providers** — OpenAI-format providers configured with a custom base URL had it ignored on the inference path; the default executor now honors `providerSpecificData.baseUrl` so requests reach the configured endpoint. ([#4002](https://github.com/diegosouzapw/OmniRoute/pull/4002) — thanks @artickc)
|
||||
- **fix(live-ws): bridge LiveWS sidecar events to the dashboard** — events emitted by the LiveWS sidecar were not reaching the dashboard; they are now bridged so live websocket activity is visible. (A cookie-auth regression in the sidecar's auth-token parsing was also corrected.) ([#4004](https://github.com/diegosouzapw/OmniRoute/pull/4004) — thanks @megamen32)
|
||||
- **fix(qwen-web): cookie validation false-positive — check the response body for a user object** — Qwen web cookie validation reported a valid cookie as invalid; it now inspects the response body for the `user` object instead of relying on the status code alone. ([#3958](https://github.com/diegosouzapw/OmniRoute/pull/3958) — thanks @thezukiru)
|
||||
- **fix(vision-bridge): force the bridge for tokenrouter deepseek models** — tokenrouter DeepSeek models are now forced through the Vision Bridge so image inputs are handled correctly. ([#3946](https://github.com/diegosouzapw/OmniRoute/pull/3946) — thanks @WormAlien)
|
||||
- **fix(api): return 400 (not 500) for malformed JSON on `/api/auth/login`** — a malformed JSON body on the login endpoint returned an opaque 500; it now returns a proper 400. ([#4031](https://github.com/diegosouzapw/OmniRoute/pull/4031) — thanks @rdself)
|
||||
- **fix(dashboard): Playground Compare tab loading + HTTP method guard** — the Playground Compare tab failed to load; the loading path was fixed and an HTTP method guard added. ([#4024](https://github.com/diegosouzapw/OmniRoute/pull/4024) — thanks @rdself)
|
||||
- **fix(proxy): gate the control-plane proxy direct fallback behind a feature flag (fail-closed)** — the direct-connection fallback for control-plane ops when a pinned proxy is unreachable is now gated behind a feature flag and fails closed, so a pinned proxy is never silently bypassed unless explicitly allowed. ([#3963](https://github.com/diegosouzapw/OmniRoute/pull/3963) — thanks @rdself)
|
||||
- **fix(db): persist backup retention days** — the backup retention-days setting was not persisted across restarts; it is now stored durably. ([#3970](https://github.com/diegosouzapw/OmniRoute/pull/3970) — thanks @rdself)
|
||||
- **fix(dashboard): refine the provider quota card display** — the provider quota card layout was refined for clearer quota/usage presentation. ([#3969](https://github.com/diegosouzapw/OmniRoute/pull/3969) — thanks @rdself)
|
||||
- **fix(dashboard): refine compression settings, storage labels, and sidebar grouping** — polishes the compression-settings UI, clarifies storage labels, and tidies the sidebar grouping. ([#4033](https://github.com/diegosouzapw/OmniRoute/pull/4033) — thanks @rdself)
|
||||
|
||||
### 🔒 Security & Hardening
|
||||
|
||||
- **fix(security): eliminate a polynomial ReDoS in the combo `<omniModel>` tag regex** — `comboAgentMiddleware`'s cache-tag pattern wrapped the tag in an unbounded newline run (`(?:\n|\r)*`), making `.test()` / `.replace()` run in O(n²) on inputs with many newlines (CodeQL `js/polynomial-redos`). The detection pattern now matches only the core `<omniModel>…</omniModel>` and the global strip pattern bounds the surrounding newline runs, keeping it linear; detection / extraction / multi-tag stripping behavior is unchanged. ([#3982](https://github.com/diegosouzapw/OmniRoute/pull/3982) — thanks @diegosouzapw)
|
||||
- **ci(security): harden workflows — artipacked `persist-credentials`, cache-poisoning, SC2086** — GitHub Actions workflows were hardened against the artipacked `persist-credentials` leak and cache-poisoning, and shell-quoting (`SC2086`) issues were fixed. ([#3965](https://github.com/diegosouzapw/OmniRoute/pull/3965) — thanks @diegosouzapw)
|
||||
- **ci(quality): flip require-tighten + osv + Trivy to blocking (cycle-end)** — the per-module require-tighten check and the OSV / Trivy scanners moved from advisory to blocking for the v3.8.27 cycle close, so new dependency or coverage regressions fail CI. ([#3984](https://github.com/diegosouzapw/OmniRoute/pull/3984) — thanks @diegosouzapw)
|
||||
- **chore(deps): dependabot security bumps + drop unused gray-matter** — applies a batch of Dependabot security bumps and removes the unused `gray-matter` dependency from the tree. ([#4036](https://github.com/diegosouzapw/OmniRoute/pull/4036) — thanks @diegosouzapw)
|
||||
- **chore(deps): automated dependency bumps** — Dependabot upgraded the production dependency group (13 updates), `vite`, `form-data`, and the `npm_and_yarn` group. ([#3915](https://github.com/diegosouzapw/OmniRoute/pull/3915), [#3942](https://github.com/diegosouzapw/OmniRoute/pull/3942), [#3943](https://github.com/diegosouzapw/OmniRoute/pull/3943), [#3944](https://github.com/diegosouzapw/OmniRoute/pull/3944) — thanks @dependabot)
|
||||
|
||||
### 🧹 Internal / Quality / Docs
|
||||
|
||||
- **feat(ci): Quality Gate v2 — Onda 0 + Onda 1** — first two waves of the Quality Gate v2 program: gate flips, test-impact analysis (TIA), SAST, DAST-smoke, and mutation-testing infrastructure. ([#4016](https://github.com/diegosouzapw/OmniRoute/pull/4016) — thanks @diegosouzapw)
|
||||
- **refactor: modularize the provider registry into individual provider plugins** — `providerRegistry.ts` was split into individual per-provider plugin modules (non-stacked). A forward-fix restored the `byteplus` + `mimocode` modules dropped by the move. ([#3993](https://github.com/diegosouzapw/OmniRoute/pull/3993) — thanks @oyi77 / @diegosouzapw)
|
||||
- **refactor: modularize schemas (non-stacked)** — the request/response schema definitions were split into individual modules to reduce file size and improve maintainability. ([#3988](https://github.com/diegosouzapw/OmniRoute/pull/3988) — thanks @oyi77)
|
||||
- **fix: restore unit regressions dropped by the lossy schema/registry modularizations** — the schema/registry modularizations (#3988, #3993) silently dropped internal logic covered by unit tests; this PR restores the regressed units. ([#4030](https://github.com/diegosouzapw/OmniRoute/pull/4030) — thanks @diegosouzapw)
|
||||
- **refactor(dashboard): settings UI layout + API Keys naming** — the settings UI layout was reorganized and the "API Keys" naming clarified. ([#4020](https://github.com/diegosouzapw/OmniRoute/pull/4020) — thanks @rdself)
|
||||
- **大量UI显示和i18n优化 (dashboard UI display + i18n improvements)** — a batch of dashboard UI-display refinements and i18n string improvements. ([#3973](https://github.com/diegosouzapw/OmniRoute/pull/3973) — thanks @rdself)
|
||||
- **fix(ci): scope TIA to `node:test` unit files only** — test-impact analysis was matching files the `node:test` runner doesn't execute, producing 99 false failures; the TIA glob now mirrors the `test:unit` glob exactly. ([#4035](https://github.com/diegosouzapw/OmniRoute/pull/4035) — thanks @diegosouzapw)
|
||||
- **fix(ci): electron-release publish-npm needs `contents: write`** — the reusable npm-publish job invoked by the electron release lacked `contents: write`, causing a v3.8.26 `startup_failure`; the permission was granted. ([#3966](https://github.com/diegosouzapw/OmniRoute/pull/3966) — thanks @diegosouzapw)
|
||||
- **test(opencode-plugin): ESM default-export test (drop the stale CJS bundle test)** — replaces the stale CJS bundle test with an ESM default-export test, following up the #3883 ESM-only migration. ([#3967](https://github.com/diegosouzapw/OmniRoute/pull/3967) — thanks @diegosouzapw)
|
||||
- **fix(ci): Fix promptfoo security-assertion parsing** — the promptfoo (DAST/security eval) assertion parser was corrected so security assertions are read reliably. ([#4032](https://github.com/diegosouzapw/OmniRoute/pull/4032) — thanks @rdself)
|
||||
- **docs(troubleshooting): note that the MITM proxy cannot intercept Windows-host apps under WSL** — documents that the MITM proxy running inside WSL cannot intercept traffic from apps on the Windows host. ([#4003](https://github.com/diegosouzapw/OmniRoute/pull/4003) — thanks @diegosouzapw)
|
||||
- **chore(quality): maintenance roll-up** — assorted quality-gate hygiene that does not change runtime behavior: re-baseline `validation.ts` for the #3958 qwen body-check, allowlist the `socks` dependency declared by #4004, ignore jscpd major bumps (the v5 Rust rewrite breaks the pinned duplication gate), untrack an accidentally-committed root `node_modules` symlink (and gitignore it), rehome the #3972 logs auto-refresh test so a runner collects it, and open the v3.8.27 development cycle. (thanks @diegosouzapw)
|
||||
|
||||
---
|
||||
|
||||
## [3.8.26] — 2026-06-15
|
||||
|
||||
### ✨ New Features
|
||||
@@ -15,6 +854,12 @@
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(executor): stop leaking `stream_options` onto non-streaming requests (NVIDIA NIM 400)** — clients that send `stream_options: { include_usage: true }` regardless of `stream` (e.g. the OpenAI Python SDK) had it passed through untouched on non-streaming calls, and NVIDIA NIM rejected it with `400 "Stream options can only be defined when stream=True"`. `DefaultExecutor.transformRequest` only injected/cleared `stream_options` on the streaming branch and had no branch to strip a client-sent value when the outbound request is non-streaming. It now strips `stream_options` whenever `stream` is false (the streaming injection path is unchanged). Affects all OpenAI-compatible providers; NIM is just the one that strictly rejects the violation. ([#3884](https://github.com/diegosouzapw/OmniRoute/issues/3884) — thanks @andrea-kingautomation / @daniij)
|
||||
- **fix(claude): forward the client-negotiated `anthropic-beta: tool-search-tool-2025-10-19` on the Claude OAuth path** — with `ENABLE_TOOL_SEARCH` active, Claude Code sends deferred tools + a `tool_search_tool_*` and negotiates the `tool-search-tool-2025-10-19` beta, but OmniRoute dropped that beta on **both** Claude code paths (the `default` executor rebuilt the header from the static `ANTHROPIC_BETA_CLAUDE_OAUTH` set, and `selectBetaFlags` only read the client beta to gate thinking/effort), so the claude.ai backend rejected every deferred-tool request with `400 Tool reference '<name>' not found in available tools`. A new allowlist-merge (`mergeClientAnthropicBeta`) now unions the client's negotiated beta into the outbound set on both paths — appending only allowlisted client betas (currently just `tool-search-tool-2025-10-19`) so it never forces betas the client didn't request (preserving the #3415 fix) nor leaks betas the backend rejects. ([#3974](https://github.com/diegosouzapw/OmniRoute/issues/3974) — thanks @huohua-dev)
|
||||
- **fix(combo): strict-random spreads fallbacks across healthy peers instead of retrying a failing model** — with the `strict-random` strategy, a model that kept failing was retried on essentially every request and traffic concentrated on a few models. The strategy shuffled only the deck-selected slot 0 and left the fallback remainder in **fixed priority order**, so after any failing deck pick the dispatch chain always fell through to the same top-priority model next. The fallback remainder is now shuffled (like the `random` strategy), so the fallback load — and recovery from a persistently-failing target — spreads evenly across the healthy peers. (Note: when the client always sends `tools` (e.g. OpenCode), the combo still correctly routes only to the tool-capable models in the combo — that capability filtering is by design.) ([#3959](https://github.com/diegosouzapw/OmniRoute/issues/3959) — thanks @KeNJiKunG)
|
||||
- **fix(dashboard): Logs auto-refresh now works even when the tab loads in the background** — the Logs page never auto-refreshed (only the manual Refresh button worked). The auto-refresh interval gated each tick on a visibility ref seeded once at mount and updated only by a `visibilitychange` event; when the tab mounted while the document reported `hidden` (background load, bfcache restore, embedded/Docker-proxied webviews) and no `visibilitychange` ever fired, the ref stayed `false` forever, so the interval ticked but never fetched. The tick now reads the live `document.visibilityState`, so polling self-heals as soon as the tab is visible — while still pausing when genuinely hidden. ([#3972](https://github.com/diegosouzapw/OmniRoute/issues/3972) — thanks @tjengbudi)
|
||||
- **fix(providers): LLM7 (and BytePlus) now fetch the live `/models` catalog instead of a stale hardcoded list** — importing an LLM7 key surfaced only a small, outdated model list even though `GET https://api.llm7.io/v1/models` returns the full pro/standard catalog. Both providers carried a correct `modelsUrl` in the registry, but neither was classified by any live-fetch branch of the model-import route (not `openai-compatible-*`, not self-hosted, not in `NAMED_OPENAI_STYLE_PROVIDERS`), so the route skipped the upstream probe and served the registry's 4 hardcoded entries (`source: "local_catalog"`). Added `llm7` and `byteplus` to `NAMED_OPENAI_STYLE_PROVIDERS` so the route probes `<baseUrl>/models` with the key and serves the live catalog, falling back to the local catalog only when the upstream fetch fails (so key import never breaks). ([#3976](https://github.com/diegosouzapw/OmniRoute/issues/3976) — thanks @FerLuisxd)
|
||||
- **fix(resilience): respect connection cooldown stored as a numeric epoch (router kept hammering 429 accounts)** — the router kept dispatching to connections still inside their rate-limit cooldown, causing client timeouts and "connection cooldown isn't respected" reports. Root cause: `rate_limited_until` is a `TEXT` column, but the Antigravity full-quota path (`setConnectionRateLimitUntil`) persists a raw epoch **number**, which SQLite coerces to a numeric string like `"1781696905131.0"`. The account-selection predicate then did `new Date("1781696905131.0")` → `Invalid Date` → `NaN`, so `NaN > Date.now()` was false and the cooling connection was never skipped. The cooldown read predicates (`isAccountUnavailable`, `getEarliestRateLimitedUntil`, `filterAvailableAccounts`, `parseFutureDateMs`) now normalize numeric-epoch strings as well as ISO strings/Date/number via a shared `cooldownUntilMs()` helper — ISO behavior is unchanged. ([#3954](https://github.com/diegosouzapw/OmniRoute/issues/3954))
|
||||
- **fix(compression/memory): stop memory + compression from poisoning the upstream prompt cache** — with compression and/or memory enabled, requests to caching providers (Anthropic-family) missed the prompt cache on every turn, multiplying cost. Two root causes: (1) memory injection prepended the retrieved memories — which **vary per user query** — at index 0 of the message array, shifting the entire cacheable prefix every turn; memory is now inserted just before the last user message when the request carries `cache_control` breakpoints, keeping the cacheable prefix (system prompt + prior turns) byte-stable. (2) the cache-aware `skipSystemPrompt` flag computed by `getCacheAwareStrategy()` was dropped by `selectCompressionStrategy()` (which can only return a mode), so the system prompt could still be compressed under caching; a new `resolveCacheAwareConfig()` now forces `preserveSystemPrompt` on for caching requests. ([#3936](https://github.com/diegosouzapw/OmniRoute/pull/3936), closes [#3890](https://github.com/diegosouzapw/OmniRoute/issues/3890) — thanks @xenstar / @diegosouzapw)
|
||||
- **fix(providers): register BytePlus ModelArk so its API key can be added** — adding a BytePlus (`ark-…`) key reported "invalid". `byteplus` was present in the provider catalog (`APIKEY_PROVIDERS`) but **never registered in the routing registry**, so key validation fell through to `{ unsupported: true }` → HTTP 400 → the UI rendered every key as invalid (and the provider was unusable for inference). Added a registry entry modeled on the existing Volcengine Ark provider: OpenAI-compatible format, base `https://ark.ap-southeast.bytepluses.com/api/v3` (region `ap-southeast-1`), `Authorization: Bearer` auth, seeded with the catalog's advertised models (Seed 2.0, Kimi K2 Thinking, GLM 4.7, GPT-OSS-120B). ([#3935](https://github.com/diegosouzapw/OmniRoute/pull/3935), closes [#3877](https://github.com/diegosouzapw/OmniRoute/issues/3877) — thanks @nikohd12 / @diegosouzapw)
|
||||
- **fix(providers): Nous Research key validation no longer fails on a stale probe model** — adding a valid Nous Research API key reported "invalid" even though the same key worked via the portal's copy-shell `curl`. The validation probe sent `model: "nousresearch/hermes-4-70b"`, which Nous does not serve, so the API returned `400` and the validator (which only treated `200`/`429` as success) reported the key invalid. The probe now uses the real `Hermes-4-70B` slug, and any non-auth 4xx (`400`/`404`/`422`) is treated as a valid key (the request shape was wrong, not the credentials) — mirroring the longcat/nvidia validators so a future model rename can't re-break key validation. ([#3934](https://github.com/diegosouzapw/OmniRoute/pull/3934), closes [#3881](https://github.com/diegosouzapw/OmniRoute/issues/3881) — thanks @FerLuisxd / @diegosouzapw)
|
||||
@@ -47,6 +892,8 @@
|
||||
- **ci(quality): fix scanner install + size-limit preset, promote `codeqlAlerts` to blocking** — corrected the scanner install and the size-limit preset, and promoted the `codeqlAlerts` ratchet from advisory to blocking. ([#3945](https://github.com/diegosouzapw/OmniRoute/pull/3945) — thanks @diegosouzapw)
|
||||
- **ci(quality): add an OpenAPI breaking-change gate (oasdiff, advisory) + fix dangling `$ref`s** — a CI gate diffs the OpenAPI spec against the base branch (`BASE_REF`) with oasdiff to surface breaking API changes, and the spec's dangling `$ref`s were repaired. ([#3951](https://github.com/diegosouzapw/OmniRoute/pull/3951) — thanks @diegosouzapw)
|
||||
- **ci(quality): add a schemathesis API-fuzz nightly (advisory)** — a nightly schemathesis property/fuzz pass against the OpenAPI spec (Quality Gates Fase 8 · Bloco B.4, advisory). ([#3956](https://github.com/diegosouzapw/OmniRoute/pull/3956) — thanks @diegosouzapw)
|
||||
- **ci(quality): flip the secret / workflow / bundle-size scanners to ratchet-blocking** — the secret-scan, workflow-lint and bundle-size gates moved from advisory to ratchet-blocking, with their baselines frozen and unit coverage for each scanner (Etapa 2). ([#3961](https://github.com/diegosouzapw/OmniRoute/pull/3961) — thanks @diegosouzapw)
|
||||
- **chore(quality): re-baseline the ESLint-warning ratchet (3760 → 3769)** — absorbs the v3.8.26-cycle warning drift into `quality-baseline.json` (manual re-baseline, never an automatic upward ratchet). ([#3962](https://github.com/diegosouzapw/OmniRoute/pull/3962) — thanks @diegosouzapw)
|
||||
- **ci(quality): wire Stryker mutation testing as an advisory nightly** — Stryker mutation testing runs nightly (advisory) — Quality Gates Fase 7 · Task 11. ([#3898](https://github.com/diegosouzapw/OmniRoute/pull/3898) — thanks @diegosouzapw)
|
||||
- **ci(quality): freeze per-module coverage floors + wire require-tighten (advisory)** — per-module coverage floors are frozen with an advisory "require-tighten" check that flags modules drifting below their floor. ([#3901](https://github.com/diegosouzapw/OmniRoute/pull/3901) — thanks @diegosouzapw)
|
||||
- **ci(quality): enforce the stale-allowlist check on `check-known-symbols`** — stale allowlist entries (suppressing a symbol that no longer exists) now fail the gate — Fase 6A.3 follow-up. ([#3899](https://github.com/diegosouzapw/OmniRoute/pull/3899) — thanks @diegosouzapw)
|
||||
@@ -970,7 +1817,7 @@ And thank you to the OmniRoute community for the bug reports, reproductions, and
|
||||
`tests/e2e/traffic-inspector.spec.ts`, `tests/e2e/agent-bridge-traffic-cross.spec.ts`
|
||||
(skip-gated on CI by `RUN_AGENT_BRIDGE_E2E` / `RUN_TRAFFIC_INSPECTOR_E2E` / `RUN_CROSS_E2E`).
|
||||
- **Documentation** — `docs/frameworks/AGENTBRIDGE.md` and `docs/frameworks/TRAFFIC_INSPECTOR.md`;
|
||||
`docs/architecture/REPOSITORY_MAP.md` updated; `docs/reference/openapi.yaml` updated with
|
||||
`docs/architecture/REPOSITORY_MAP.md` updated; `docs/openapi.yaml` updated with
|
||||
~28 new routes and 20+ new schemas.
|
||||
- **i18n:** translate Ukrainian (uk-UA) menu and UI strings, plus complete uk-UA UI coverage (#2981 / #2988 — thanks @Lion-killer)
|
||||
- **providers:** add SiliconFlow endpoint selector (#2975 — thanks @xz-dev)
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -35,7 +35,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 226 LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 231 LLM providers, auto-fallback.
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -311,7 +311,7 @@ connection continue serving other models.
|
||||
4. Create 7 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`.
|
||||
5. Verify `/api/services/` is in `LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`; add a test asserting `isLocalOnlyPath()` returns `true` for the new prefix if you add one (hard rule #17).
|
||||
6. Add a UI tab in `src/app/(dashboard)/dashboard/providers/services/tabs/` reusing `ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`.
|
||||
7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/reference/openapi.yaml`.
|
||||
7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/openapi.yaml`.
|
||||
8. Write tests: unit (`tests/unit/services/`), integration (`tests/integration/services/`, gated by `RUN_SERVICES_INT=1`), and update `docs/ops/RELEASE_CHECKLIST.md` smoke section.
|
||||
|
||||
### Adding a New Guardrail / Eval / Skill / Webhook event
|
||||
@@ -349,7 +349,7 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
| Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` |
|
||||
| MCP server | `docs/frameworks/MCP-SERVER.md` |
|
||||
| A2A server | `docs/frameworks/A2A-SERVER.md` |
|
||||
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/reference/openapi.yaml` |
|
||||
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/openapi.yaml` |
|
||||
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
|
||||
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
|
||||
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
@@ -433,13 +433,14 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
|
||||
|
||||
In Claude Code prefer the native `EnterWorktree` tool (create the worktree with the command
|
||||
above, then call `EnterWorktree` with its `path`).
|
||||
|
||||
3. **Work, commit, push, open the PR — all from inside the worktree.** Never `git checkout` a
|
||||
different branch inside a worktree another session might share.
|
||||
4. **Tear down only your own** worktree + branch when done, from the main checkout:
|
||||
`git worktree remove .worktrees/<dir>` then `git branch -D <task>`. Never blanket-delete
|
||||
`fix/*`/`feat/*` — other sessions keep their own; delete only the branches you created, by name.
|
||||
5. **Never touch another session's worktree, branch, or uncommitted changes.** If `git worktree
|
||||
list` shows worktrees you didn't create, leave them alone. End every session with the main
|
||||
list` shows worktrees you didn't create, leave them alone. End every session with the main
|
||||
checkout back on the branch it started on (the active `release/vX.Y.Z`, never `main`).
|
||||
|
||||
---
|
||||
@@ -500,7 +501,7 @@ the stale-enforcement added in Fase 6A.3.
|
||||
13. Never string-interpolate external paths or runtime values into shell scripts passed to `exec()`/`spawn()` — pass via the `env` option instead. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`.
|
||||
15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
16. Never include `Co-Authored-By` trailers that credit an AI assistant, LLM, or automation account (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses). Such trailers route attribution to the bot account on GitHub, hiding the real author (`diegosouzapw`) in PR history. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
|
||||
16. Never credit or advertise an AI assistant, LLM, or automation account in any commit/PR metadata. Two forbidden forms, both equivalent — they route attribution to a bot account (or advertise AI authorship) and hide the real author (`diegosouzapw`): **(a)** `Co-Authored-By` trailers naming an AI/bot (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses); **(b)** AI-generation footers or descriptions anywhere in a commit message, PR title/body, or CHANGELOG — e.g. `🤖 Generated with [Claude Code]`, "Generated with Claude Code", "Made with <AI tool>", or any `Co-authored-by: Claude/GPT/Copilot` line. This **overrides any harness, template, or tool default that auto-appends such a footer** (e.g. the Claude Code PR-body/commit default) — strip it before pushing; do not let it reach a commit, PR, or CHANGELOG. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
|
||||
17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree.
|
||||
19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation".
|
||||
|
||||
254
DESING.md
Normal file
254
DESING.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# OmniRoute — Design System & Visual Identity
|
||||
|
||||
> **Status:** analysis + standardization plan (no code applied yet — this doc is the spec to approve before implementation).
|
||||
> **Date:** 2026-06-16 · **Scope:** unify the OmniRoute dashboard (`src/`) with the marketing site (`_mono_repo/omnirouteSite/`) into **one visual identity** — same graph-paper grid background, same color tokens, standardized components.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The marketing site (`viral.omniroute.online`, `why.omniroute.online`, `omniroute.online`) and the product dashboard should look like **one product**. The site already borrowed its palette from the dashboard — its `css/tokens.css` even says _"Palette mirrors the OmniRoute dashboard (src/app/globals.css)"_. So the two are already ~80% aligned at the color level. What's missing on the dashboard:
|
||||
|
||||
1. The **graph-paper grid wallpaper** the site uses on every page.
|
||||
2. A handful of **shared design tokens** the site has but the dashboard lacks (radius scale, brand gradient, `surface-2`, mono font).
|
||||
3. **Component-level consistency** — a number of dashboard components bypass the theme tokens with hardcoded hex/rgba.
|
||||
|
||||
This document is the analysis and the plan. **Nothing is changed until approved.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principles
|
||||
|
||||
- **Single source of truth = `src/app/globals.css`.** The site mirrors the dashboard, never the other way around. New tokens land in `globals.css` first.
|
||||
- **Tokens, never literals.** Components consume semantic tokens (`bg-surface`, `text-primary`, `border-border`), never raw `#hex`.
|
||||
- **Subtle, not loud.** The grid is a faint wallpaper that sits behind content — it must never reduce text contrast or fight the UI.
|
||||
- **Theme-aware.** Everything works in both `.dark` (default-ish, the product's signature look) and light.
|
||||
- **Surgical rollout.** Ship the grid + tokens first (low risk, high visibility), then component cleanups in waves.
|
||||
|
||||
---
|
||||
|
||||
## 3. Current state — what's already aligned vs. what's not
|
||||
|
||||
### 3.1 Colors — already unified ✅
|
||||
|
||||
Every brand color and surface already matches the site **by value** (only the names differ — dashboard prefixes with `--color-`). Verified in `src/app/globals.css:30-128`:
|
||||
|
||||
| Concept | Site token (`tokens.css`) | Dashboard token (`globals.css`) | Match |
|
||||
| -------------------------- | ------------------------------------------- | ------------------------------- | ------------ |
|
||||
| primary | `--primary #e54d5e` | `--color-primary #e54d5e` | ✅ |
|
||||
| primary-hover | `--primary-hover #c93d4e` | `--color-primary-hover #c93d4e` | ✅ |
|
||||
| accent | `--accent #6366f1` | `--color-accent #6366f1` | ✅ |
|
||||
| accent-2 | `--accent-2 #8b5cf6` | `--color-accent-hover #8b5cf6` | ✅ (renamed) |
|
||||
| accent-3 | `--accent-3 #a855f7` | `--color-accent-light #a855f7` | ✅ (renamed) |
|
||||
| success / warning / error | `#22c55e / #f59e0b / #ef4444` | identical | ✅ |
|
||||
| traffic lights | `#ff5f56 / #ffbd2e / #27c93f` | identical | ✅ |
|
||||
| dark bg / surface / border | `#0b0e14 / #161b22 / rgba(255,255,255,.08)` | identical | ✅ |
|
||||
| light bg / surface / text | `#f9f9fb / #fff / #1a1a2e` | identical | ✅ |
|
||||
|
||||
**Conclusion:** there is no color migration to do. The identity is already shared; we are _finishing_ it, not rebuilding it.
|
||||
|
||||
### 3.2 Gaps — what the dashboard is missing
|
||||
|
||||
| Gap | Site has | Dashboard | Action |
|
||||
| ----------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------- | ---------------------- |
|
||||
| **Grid wallpaper** | `body::before` graph-paper, `--grid-line`, `--grid-size 46px`, `--section-alt` | none (flat `--color-bg`) | **Part A** |
|
||||
| **Radius scale** | `--radius 14px`, `--radius-sm 9px` | none — primitives use ad-hoc `rounded-md/lg/xl` (6/8/12px) | **Part B** |
|
||||
| **Brand gradient** | `--grad-brand 135deg primary→accent-3` | none — only a one-off `.bg-hero-gradient` | **Part B** |
|
||||
| **Nested surface** | `--surface-2 #1c2230` | none | **Part B** |
|
||||
| **Mono font** | `--font-mono` (ui-monospace stack) | none (code/terminal areas have no token) | **Part B** |
|
||||
| **`text-muted` (dark)** | `#8b8b9e` | `#a1a1aa` (zinc-400) | reconcile — **Part B** |
|
||||
|
||||
### 3.3 Theming mechanics (so we don't break anything)
|
||||
|
||||
- **Tailwind v4, CSS-first** (no `tailwind.config.*`). Tokens are defined in `:root`/`.dark` and exposed to utilities via `@theme inline` (`globals.css:130-179`).
|
||||
- **Dark via `.dark` class** on `<html>` (`@custom-variant dark` at `globals.css:22`), toggled by a custom Zustand store (`src/store/themeStore.ts`), default theme = `system` (`src/shared/constants/appConfig.ts:11`). The site uses `html[data-theme="light"]` instead — **the mechanisms differ but never meet** (separate origins), so no conflict. We keep the dashboard's `.dark` mechanism.
|
||||
- **Runtime primary override** exists (`themeStore.ts:85-97`, presets in `COLOR_THEMES`) — users can swap `--color-primary`. Any new token (gradient, etc.) that references `--color-primary` will inherit those overrides for free. ✅
|
||||
|
||||
---
|
||||
|
||||
## 4. Part A — The graph-paper grid background (headline ask)
|
||||
|
||||
### 4.1 What it is
|
||||
|
||||
The exact recipe from the site (`_mono_repo/omnirouteSite/css/base.css`): a **fixed, full-viewport pseudo-element** painting two 1px line gradients, sitting at `z-index:-1` behind all content.
|
||||
|
||||
```css
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: var(--grid-size) var(--grid-size);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works even though `body` has an opaque `background-color`:** a `::before` with `z-index:-1` paints _above_ the element's own background but _below_ its in-flow content. So `--color-bg` is the base fill, the grid is layered on top of it, and the app renders above the grid.
|
||||
|
||||
### 4.2 Precedent already in the codebase
|
||||
|
||||
`src/app/landing/page.tsx:16-26` **already implements this same grid per-page** — but with **red** lines (`#E54D5E`, opacity `0.06`) at **50px**, plus animated orbs. So the pattern is proven in the product; we are promoting it to a **global, theme-aware** wallpaper and (optionally) retiring the duplicate.
|
||||
|
||||
### 4.3 Tokens to add (in `globals.css`)
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* light */
|
||||
--grid-line: rgba(0, 0, 0, 0.045);
|
||||
--grid-size: 46px;
|
||||
--section-alt: rgba(0, 0, 0, 0.022);
|
||||
}
|
||||
.dark {
|
||||
/* dark */
|
||||
--grid-line: rgba(255, 255, 255, 0.035);
|
||||
--section-alt: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 The single blocker
|
||||
|
||||
The grid is global by construction (it covers the panel, `auth`/`login`, error pages — every route — at once). Exactly **one** element hides it inside the panel:
|
||||
|
||||
- `src/shared/components/layouts/DashboardLayout.tsx:62` — the outer wrapper paints an opaque `bg-bg`:
|
||||
|
||||
```jsx
|
||||
<div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
```
|
||||
|
||||
Everything below it is already transparent — `<main>` (`:93`), the scroll container (`:102`), the `max-w-7xl` inner (`:103`). So **removing `bg-bg` from this one line** lets the body grid show through the entire content area (the body's `--color-bg` remains the base fill underneath the grid).
|
||||
|
||||
```diff
|
||||
- <div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
+ <div className="flex h-dvh min-h-0 w-full overflow-hidden">
|
||||
```
|
||||
|
||||
### 4.5 Chrome interaction (sidebar / header)
|
||||
|
||||
- `Header` (`src/shared/components/Header.tsx:207`, `bg-bg`) and `Sidebar` (`src/shared/components/Sidebar.tsx:430`, `bg-sidebar`) stay **opaque** → the grid shows in the **content area only**, with solid chrome framing it. This is the recommended, calm default and matches how the site separates chrome from canvas.
|
||||
- _Optional vibrancy variant:_ make the header translucent (`bg-bg/80 backdrop-blur`) so the grid runs behind it. A `.bg-vibrancy` helper already exists (`globals.css:370`). **Decision D3 below.**
|
||||
|
||||
### 4.6 Login / auth / error pages
|
||||
|
||||
These render directly under `<body>` (no panel chrome) and their page wrappers are mostly transparent — the global grid appears behind them automatically. One exception: `src/app/login/page.tsx:124,139` uses opaque `bg-bg` wrappers; soften the same way if we want the grid there too (minor, **D4**).
|
||||
|
||||
### 4.7 Landing page
|
||||
|
||||
`landing/page.tsx` keeps its richer animated background (orbs + vignette). Options: (a) leave it as-is (its own splash identity), or (b) align its grid to the global tokens (46px, neutral lines) for consistency. **Recommend (a)** — it's a marketing splash, not a panel screen. **Decision D5.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Part B — Token unification
|
||||
|
||||
Add to `globals.css` (`:root` + `@theme inline`) so the dashboard gains the site's missing tokens. None of these change existing colors; they add the _missing_ primitives.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--surface-2: #f5f5fa; /* light: nested panels */
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--grad-brand: linear-gradient(135deg, var(--color-primary), var(--color-accent-light));
|
||||
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
}
|
||||
.dark {
|
||||
--surface-2: #1c2230;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-surface-2: var(--surface-2); /* enables bg-surface-2 */
|
||||
--radius-lg: var(--radius); /* enables rounded-lg = 14px */
|
||||
--radius-md: var(--radius-sm); /* enables rounded-md = 9px */
|
||||
--font-mono: var(--font-mono); /* enables font-mono */
|
||||
}
|
||||
```
|
||||
|
||||
| Token | Why | Consumers |
|
||||
| -------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `--radius` / `--radius-sm` | One radius scale (14/9) instead of 6/8/12 ad-hoc | Button, Card, Modal, Input, Select |
|
||||
| `--grad-brand` | Brand gradient for primary CTAs (red→violet), matching the site | Button `primary`, hero/CTA surfaces |
|
||||
| `--surface-2` | Nested panels / table headers / inset rows | Card.Section, DataTable header, inputs |
|
||||
| `--font-mono` | Code blocks, terminal, IDs, endpoints | ConsoleLogViewer, code snippets, `localhost:20128/v1` chips |
|
||||
| `--text-muted` reconcile | Pick one value site↔panel | global |
|
||||
|
||||
**Decision D2 (text-muted):** site `#8b8b9e` vs dashboard `#a1a1aa`. Recommend keeping the **dashboard's `#a1a1aa`** (it's the live product, slightly higher contrast) and updating the _site_ to match. Low priority, cosmetic.
|
||||
|
||||
---
|
||||
|
||||
## 6. Part C — Component standardization
|
||||
|
||||
The component layer is **custom** (no shadcn/Radix), Tailwind v4, semantic tokens **mostly** adopted (`bg-surface`, `border-white/10`, `ring-primary`) — good adoption (195 files import the shared barrel). The work is removing the **bypasses**. Home: `src/shared/components/`.
|
||||
|
||||
Ranked by impact × reach:
|
||||
|
||||
| # | Item | File(s) | Problem → Target |
|
||||
| --- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| C1 | **Radius alignment** | `Button.tsx:14-18`, `Card.tsx:39`, `Modal.tsx`, `Input.tsx`, `Select.tsx` | mixed 6/8/12px → repoint to `--radius`/`--radius-sm` (14/9) |
|
||||
| C2 | **Button gradient + `accent` variant** | `Button.tsx:5-12` | primary is flat red→red (`from-primary to-primary-hover`); align to `--grad-brand` (red→violet) and add the missing `accent` variant (indigo `#6366f1` is unused by buttons) — **highest visibility, ~195 importers**. **Decision D1.** |
|
||||
| C3 | **Tables** | `DataTable.tsx:122-176`, `logTableStyles.ts`, `globals.css:405-414` (Ant remnants) | `DataTable` is 100% inline hardcoded rgba + references non-existent vars (`--text-secondary`, `--bg-table-header`); migrate to tokens, retire the 2 divergent table styles. Tables are everywhere (providers/connections/logs) — worst offender. |
|
||||
| C4 | **Centralize status colors** | `flow/edgeStyles.ts:7-12`, `TokenHealthBadge.tsx:14-19`, `DegradationBadge.tsx`, `ProviderCascadeNode.tsx`, `Badge.tsx`, +5 `statusColor` helpers | 6+ copies of the same `#22c55e/#f59e0b/#ef4444` hex; create one `statusColors` module driven off `--color-success/warning/error`. Critical for circuit-breaker / cooldown / lockout badges to read consistently. |
|
||||
| C5 | **Card border** | `Card.tsx:39` | uses `border-white/5`; brand border is `/8` → align |
|
||||
| C6 | **Focus ring reconcile** | `globals.css:183` vs component `ring-primary/30` | global `:focus-visible` is indigo (`--color-accent`), components are red (`ring-primary`) — pick one (recommend **accent/indigo** globally, it reads as the "interactive" color) |
|
||||
| C7 | **Add `Checkbox` + `Textarea` primitives** | currently raw `<input>`/`<textarea>` with inline `accentColor:#6366f1` (e.g. `ColumnToggle.tsx:91`) | create token-driven primitives |
|
||||
| C8 | **Hardcoded-hex sweep** | `ConsoleLogViewer.tsx:240` (`#161b22`/`#30363d`), `ComboLiveStudio.tsx:306` (`#6366f120`), Modal traffic dots `Modal.tsx:149-159`, ~14 chart/component files with literal `#6366f1`/`#a855f7` | replace literals with `bg-surface`/`border-border`/`text-accent` etc. |
|
||||
| C9 | **`cn()` → clsx + tailwind-merge** | `src/shared/utils/cn.ts` | current `cn` just joins; conflicting classes stack (a `className="rounded-2xl"` override won't replace a primitive's `rounded-lg`). Needed for C1 overrides to behave. |
|
||||
|
||||
**Already on-brand (token-driven, only need radius):** `Badge`, `Toggle`, `SegmentedControl`, `Input`, `Select`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout plan (phased, each phase shippable + testable)
|
||||
|
||||
- **Phase 1 — Grid + tokens (low risk, high visibility).**
|
||||
1. Add grid + identity tokens to `globals.css` (Part A §4.3, Part B §5).
|
||||
2. Add `body::before` grid.
|
||||
3. Remove `bg-bg` from `DashboardLayout.tsx:62`.
|
||||
4. Verify across themes + key screens (dashboard, providers, logs, login, an error page). Confirm contrast unchanged.
|
||||
→ _Delivers the headline ask. Reversible in one commit._
|
||||
|
||||
- **Phase 2 — Primitives radius + Button (C1, C2, C5, C9).** The visible "feel" pass. `cn()` upgrade first so overrides behave.
|
||||
|
||||
- **Phase 3 — Tables + status colors (C3, C4).** The largest consistency win; touch the data-heavy screens.
|
||||
|
||||
- **Phase 4 — Cleanup (C6, C7, C8).** Focus ring, new primitives, hardcoded-hex sweep.
|
||||
|
||||
Each phase: `npm run lint` + `npm run typecheck:core` + a visual pass. Per repo rule, production-code changes ship with tests where applicable (token/CSS changes are visual — validated by screenshots; component API changes get unit coverage).
|
||||
|
||||
---
|
||||
|
||||
## 8. Open decisions (need your call before/while implementing)
|
||||
|
||||
- **D1 — Button primary look.** Keep the current **red→red** gradient, or switch the product's primary buttons to the **red→violet `--grad-brand`** (matches the site CTAs)? _(Affects every primary button.)_ Recommend: **red→violet**, with `--grad-brand`.
|
||||
- **D2 — Grid line color.** **Neutral** lines (site style: faint white/black, `rgba(255,255,255,0.035)`) — calm, content-first — **or** the landing's **brand-red** lines? Recommend: **neutral** (matches the site's interior pages; red is louder and can tint readability). Size **46px** (site) to retire the landing's 50px drift.
|
||||
- **D3 — Chrome vibrancy.** Sidebar/header stay **solid** (grid in content area only), or go **translucent** so the grid runs behind them? Recommend: **solid** (calmer; less risk).
|
||||
- **D4 — Auth/login grid.** Soften `login/page.tsx` wrappers so the grid shows there too? Recommend: **yes** (cheap, more cohesive).
|
||||
- **D5 — Landing page.** Leave its animated splash bg as-is, or align it to the global grid? Recommend: **leave as-is**.
|
||||
- **D6 — Radius value.** Adopt **14/9** everywhere (bigger, softer, site-matching) — confirm you want this product-wide shift. Recommend: **yes**, it's the single biggest "one identity" signal.
|
||||
- **D7 — Scope of first PR.** Ship **Phase 1 only** first (grid + tokens), then iterate? Recommend: **yes** — validate the wallpaper live before the component waves.
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of scope / risks
|
||||
|
||||
- **No palette change** — colors already match; we only add missing tokens. Zero risk of recoloring the product.
|
||||
- **No theme-engine change** — keep `.dark` + Zustand store; don't migrate to `next-themes` or to the site's `data-theme`.
|
||||
- **Radius shift is broad** (D6) — it touches every card/button/input; that's the point, but it's the one change worth eyeballing on busy screens (tables, modals) before merge.
|
||||
- **Tables (C3)** carry the most hardcoded styling and the highest regression surface — isolate in its own PR with before/after screenshots.
|
||||
- **Worktree isolation (repo hard-rule #19):** implementation runs in a dedicated worktree on a branch cut from the confirmed base (likely `release/v3.8.28`), never on the shared checkout. This doc is the only artifact written to the working tree so far.
|
||||
|
||||
---
|
||||
|
||||
## 10. Reference index
|
||||
|
||||
| Area | Path |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dashboard tokens | `src/app/globals.css:30-179` (`:root`, `.dark`, `@theme inline`), `body` `:206` |
|
||||
| Theme store | `src/store/themeStore.ts`, `src/shared/components/ThemeProvider.tsx`, `src/shared/constants/appConfig.ts:9-11` |
|
||||
| Panel shell (grid blocker) | `src/shared/components/layouts/DashboardLayout.tsx:62` |
|
||||
| Chrome | `src/shared/components/Header.tsx:207`, `src/shared/components/Sidebar.tsx:430` |
|
||||
| Grid precedent | `src/app/landing/page.tsx:16-26` |
|
||||
| Primitives | `src/shared/components/{Button,Card,Input,Select,Badge,Modal,Toggle,SegmentedControl,Loading,Tooltip,DataTable}.tsx`, barrel `index.tsx` |
|
||||
| Status-color sources | `flow/edgeStyles.ts`, `TokenHealthBadge.tsx`, `DegradationBadge.tsx`, `logTableStyles.ts` |
|
||||
| `cn` util | `src/shared/utils/cn.ts` |
|
||||
| Site reference | `_mono_repo/omnirouteSite/css/tokens.css`, `css/base.css` (grid `body::before`) |
|
||||
20
Dockerfile
20
Dockerfile
@@ -38,8 +38,24 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
&& npm rebuild better-sqlite3 \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# Use Turbopack for significant build speedup
|
||||
ENV OMNIROUTE_USE_TURBOPACK=1
|
||||
# Build with webpack (stable). Turbopack hit a non-recoverable internal panic on this
|
||||
# Next.js version during the v3.8.27 release build — TurbopackInternalError "entered
|
||||
# unreachable code: there must be a path to a root" in ImportTracer::get_traces, on both
|
||||
# linux/amd64 and linux/arm64. Webpack is the proven engine (build:release / VPS / CI Build
|
||||
# all green). Re-enable Turbopack (=1) once the upstream tracer bug is fixed.
|
||||
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
|
||||
ENV OMNIROUTE_USE_TURBOPACK=0
|
||||
|
||||
# Raise the V8 heap ceiling for the build. The webpack production optimization
|
||||
# pass (forced above since Turbopack panics) needs more than V8's default ceiling
|
||||
# (~2 GB) for a codebase this size; a memory-constrained Docker build otherwise
|
||||
# dies with "FATAL ERROR: ... JavaScript heap out of memory" at `[builder] npm run
|
||||
# build` (#4076). NODE_OPTIONS propagates to the spawned `next build` child
|
||||
# (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env). Build-only;
|
||||
# the runtime heap is set separately on the runner stage (OMNIROUTE_MEMORY_MB).
|
||||
# Override for hosts with more/less RAM: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,target=/app/.build/next/cache \
|
||||
|
||||
207
README.md
207
README.md
@@ -6,7 +6,7 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
### Never stop coding. Connect every AI tool to **226 providers** — **50+ free** — through one endpoint.
|
||||
### Never stop coding. Connect every AI tool to **231 providers** — **50+ free** — through one endpoint.
|
||||
|
||||
**Plug Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini. Auto-fallback.**
|
||||
<br/>
|
||||
@@ -15,13 +15,13 @@
|
||||
|
||||
<br/>
|
||||
|
||||
**~1.9B+ documented free tokens/month** — up to **~2.5B in your first month** with signup credits — aggregated across the free tiers, and the compression above stretches every one further. ([how we count →](docs/reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate))
|
||||
**~1.6B documented free tokens/month** — up to **~2.1B in your first month** with signup credits — aggregated across the free tiers, plus a long tail of permanently-free, no-cap providers, and the compression above stretches every one further. ([how we count →](docs/reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate))
|
||||
|
||||
<br/>
|
||||
|
||||
[](#-226-ai-providers--50-free)
|
||||
[](#-226-ai-providers--50-free)
|
||||
[](docs/reference/FREE_TIERS.md)
|
||||
[](#-231-ai-providers--50-free)
|
||||
[](#-231-ai-providers--50-free)
|
||||
[](docs/reference/FREE_TIERS.md)
|
||||
[](#%EF%B8%8F-save-1595-tokens--automatically)
|
||||
[](#-combos--the-flagship)
|
||||
[](#-quick-start)
|
||||
@@ -30,12 +30,12 @@
|
||||
|
||||
### 💬 Join the community
|
||||
|
||||
[](https://discord.gg/hmexnhgE)
|
||||
[](https://discord.gg/EkzRkpzKYt)
|
||||
[](https://t.me/omnirouteOficial)
|
||||
[](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t)
|
||||
[](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)
|
||||
|
||||
**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/hmexnhgE) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 Brasil](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)**
|
||||
**Questions, provider tips, roadmap & support → [Discord](https://discord.gg/EkzRkpzKYt) · [Telegram](https://t.me/omnirouteOficial) · WhatsApp [🌍 Global](https://chat.whatsapp.com/JI7cDQ1GyaiDHhVBpLxf8b?mode=gi_t) / [🇧🇷 Brasil](https://chat.whatsapp.com/BTGJXIyjeNIIgExvTMGGhI)**
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<br/>
|
||||
|
||||
[**🚀 Quick Start**](#-quick-start) • [**🎯 Combos**](#-combos--the-flagship) • [**🌐 Providers**](#-226-ai-providers--50-free) • [**🔌 CLI & MCP**](#-full-cli--a2a--mcp) • [**🗜️ Compression**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 Website**](https://omniroute.online)
|
||||
[**🚀 Quick Start**](#-quick-start) • [**🎯 Combos**](#-combos--the-flagship) • [**🌐 Providers**](#-231-ai-providers--50-free) • [**🔌 CLI & MCP**](#-full-cli--a2a--mcp) • [**🗜️ Compression**](#%EF%B8%8F-save-1595-tokens--automatically) • [**🌍 Website**](https://omniroute.online)
|
||||
|
||||
[💥 The Promise](#-the-promise) • [🤔 Why](#-why-omniroute) • [🏆 What Sets Apart](#-what-sets-omniroute-apart) • [🤖 Compatible CLIs](#-compatible-clis--coding-agents) • [🖥️ Where It Runs](#%EF%B8%8F-where-omniroute-runs--anywhere) • [🔒 Private](#-private--local-first) • [🎬 In Action](#-omniroute-in-action) • [📚 Explore More](#-explore-more) • [📧 Support](#-support--community)
|
||||
|
||||
@@ -114,14 +114,15 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 💰 ~1.9B Free Tokens / Month
|
||||
# 💰 ~1.6B 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 aggregates the **documented** free tiers of **50+ provider pools / 530 models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`).
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute aggregates the **documented** free tiers of **40+ provider pools / 500+ models** into one honest number and shows it live on the dashboard (`/dashboard/free-tiers`).
|
||||
|
||||
- **~1.9B free tokens / month** (steady) — and **up to ~2.5B in your first month** with signup credits.
|
||||
- **Pool-deduped, honest** — we count each shared free pool **once**, so the headline isn't inflated by rate-limit ceilings the way multi-billion competitor claims are. (The naïve per-model sum would read ~8B; we don't publish that.)
|
||||
- **~1.6B free tokens / month** (steady) — and **up to ~2.1B in your first month** with signup credits.
|
||||
- **Pool-deduped, honest** — we count each shared free pool **once**, so the headline isn't inflated by rate-limit ceilings the way multi-billion competitor claims are. (Counting every rate limit 24/7 would read ~10B; we don't publish that.)
|
||||
- **Plus the un-countable** — permanently-free, no-token-cap providers (SiliconFlow, Z.AI GLM-Flash, Kilo, OpenCode Zen…) and a **$10 OpenRouter top-up** that unlocks **+24M/mo**, both surfaced separately so they never inflate the headline.
|
||||
- **Per-model breakdown**, **live used / remaining** for the current month, and a transparent **terms flag** per provider.
|
||||
|
||||

|
||||
@@ -136,11 +137,11 @@
|
||||
|
||||
</div>
|
||||
|
||||
> One endpoint. **226 providers.** Never stop building — and let OmniRoute pick the cheapest one that works.
|
||||
> One endpoint. **231 providers.** Never stop building — and let OmniRoute pick the cheapest one that works.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top"><b>🚫 Never hit limits</b><br/><sub>Auto-fallback across 226 providers in milliseconds. Quota out? Next provider takes over — zero downtime.</sub></td>
|
||||
<td width="33%" valign="top"><b>🚫 Never hit limits</b><br/><sub>Auto-fallback across 231 providers in milliseconds. Quota out? Next provider takes over — zero downtime.</sub></td>
|
||||
<td width="33%" valign="top"><b>💸 Save up to 95% tokens</b><br/><sub>RTK + Caveman stacked compression cuts 15–95% of eligible tokens (~89% avg on tool-heavy sessions).</sub></td>
|
||||
<td width="33%" valign="top"><b>🆓 $0 to start</b><br/><sub>50+ providers with a free tier, 11 free <i>forever</i> (Kiro, Qoder, Pollinations, LongCat…). No card needed.</sub></td>
|
||||
</tr>
|
||||
@@ -263,7 +264,7 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
| Feature | OmniRoute | Other routers |
|
||||
| -------------------------------------- | ----------------------------------------------------------- | ------------- |
|
||||
| 🌐 Providers | **226** | 20–100 |
|
||||
| 🌐 Providers | **231** | 20–100 |
|
||||
| 🆓 Free providers | **50+ (11 free forever)** | 1–5 |
|
||||
| 🔀 Routing strategies | **15** (priority, weighted, cost-optimized, context-relay…) | 1–3 |
|
||||
| 🗜️ Token compression | **RTK + Caveman stacked (15–95%)** | None / 20–40% |
|
||||
@@ -282,6 +283,28 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
<div align="center">
|
||||
|
||||
# ✨ What's New
|
||||
|
||||
</div>
|
||||
|
||||
> Recent highlights from **v3.8.20 → v3.8.36**. Full history in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
- **⚖️ Quota-Share routing** — a dedicated combo strategy that spreads load across accounts by _available quota_: Deficit-Round-Robin scheduling, per-connection `max_concurrent` with cooldown-wait queueing, multi-window usage buckets (5h / 7d / per-model), per-(key,model) caps, session stickiness for prompt-cache integrity, and proactive saturation from upstream token-usage headers. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
||||
- **🤖 One-command CLI/agent setup** — a dedicated `setup-*` command configures each coding tool to route through OmniRoute (Claude Code, Codex, Cline, Continue, Cursor, Roo Code, Kilo Code, Crush, Goose, Qwen Code, Aider, OpenCode, Gemini CLI); `omniroute launch` / `omniroute launch-codex` are zero-config launchers. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
- **🛰️ Remote mode** — drive a remote OmniRoute from any machine with scoped access tokens (`omniroute connect` / `omniroute contexts` / `omniroute tokens`). → [Remote Mode](docs/guides/REMOTE-MODE.md)
|
||||
- **🧭 Smarter auto-routing** — OpenRouter-style `auto/<category>:<tier>` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), a **Fusion** strategy (16th — fan out to a panel of models in parallel, then synthesize via a judge), **task-aware routing** (best-fit connection per task type), per-request `X-Route-Model` override, live Arena-ELO + models.dev model intelligence, per-step account allowlists, provider-wildcard combo steps, nested combo-ref execution, sticky weighted selection, and `web_search`-aware routing. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
- **🗜️ Pluggable compression** — an async pipeline of **9 composable engines** with Compression Studios, an LLMLingua-2 ONNX engine and a heuristic/SLM two-tier **Ultra**, RTK, delegated Anthropic Context Editing, **Output Styles** (output-axis steering: terse-prose / less-code / terse-CJK), an **adaptive context-budget dial** (escalate only as far as needed to fit the context window), per-request `x-omniroute-compression` control, an opt-in offline eval harness, and a unified panel with named profiles + an active-profile selector. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||
- **🕵️ Transparent MITM decrypt (TPROXY)** — capture & translate traffic from CLIs that ignore proxy env vars, with a per-SNI certificate authority and a trust-store installer. → [MITM/TPROXY](docs/security/MITM-TPROXY-DECRYPT.md)
|
||||
- **💸 Cost telemetry everywhere** — `X-OmniRoute-*` cost/usage headers on every endpoint (including media), a non-token cost engine, a cache-HIT `X-OmniRoute-Cost-Saved` header, and per-key USD spend quotas. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🧠 Memory you control** — opt-in int8 vector quantization (Qdrant + sqlite-vec), memory off by default, and a per-request `x-omniroute-no-memory` header. → [Memory](docs/frameworks/MEMORY.md)
|
||||
- **🛡️ Security** — a prompt-injection guard across every LLM route (backed by a red-team suite), plus a free DuckDuckGo last-resort web search. → [Guardrails](docs/security/GUARDRAILS.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, a refreshed 231-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech / transcription / music / video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **⚡ Local performance & infra** — a one-click local Redis launcher (`omniroute redis up`, plus a dashboard Redis panel), one-click **Cloudflare Workers** and **Deno Deploy** relay deployers wired into the proxy pool, and an optional Bifrost Go sidecar that offloads the hottest relay path (`BIFROST_BASE_URL`, with automatic fallback to the TypeScript path on timeout). → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 🤖 Compatible CLIs & Coding Agents
|
||||
|
||||
> One config — `http://localhost:20128/v1` — and **every** AI IDE or CLI runs on free & low-cost models.
|
||||
@@ -319,11 +342,11 @@ Result: 4 layers of fallback = zero downtime
|
||||
|
||||
<div align="center">
|
||||
|
||||
# 🌐 226 AI Providers — 50+ Free
|
||||
# 🌐 231 AI Providers — 50+ Free
|
||||
|
||||
</div>
|
||||
|
||||
> The most complete catalog of any open-source router: **226 providers**, **50+ with a free tier**, **11 free forever**.
|
||||
> The most complete catalog of any open-source router: **231 providers**, **50+ with a free tier**, **11 free forever**.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -407,6 +430,22 @@ omniroute setup # guided first-run wizard
|
||||
omniroute doctor # diagnose providers, ports, native deps
|
||||
```
|
||||
|
||||
### 🛰️ Remote mode — run the CLI here, OmniRoute on a VPS
|
||||
|
||||
OmniRoute on a server? Drive it from your laptop with the **same CLI**. Log in once
|
||||
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 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
|
||||
```
|
||||
|
||||
Tokens are scoped `read` / `write` / `admin`; process-spawning routes stay loopback-only.
|
||||
<sub>📖 [Remote Mode](docs/guides/REMOTE-MODE.md)</sub>
|
||||
|
||||
<div align="center">
|
||||
|
||||
`providers` · `oauth` · `keys` · `combo` · `nodes` · `models` · `cache` · `compression` · `cost` · `usage` · `quota` · `health` · `resilience` · `telemetry` · `logs` · `audit` · `mcp` · `a2a` · `cloud` · `memory` · `skills` · `eval` · `tunnel` · `backup` · `sync` · `webhooks` · `policy` · `pricing` · `translator` · `simulate` …
|
||||
@@ -445,17 +484,17 @@ claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp
|
||||
|
||||
Engines run in pipeline order; each is independently toggleable and configurable per combo:
|
||||
|
||||
| # | Engine | What it does |
|
||||
| --- | ----------------- | ------------------------------------------------------------------------ |
|
||||
| 1 | **Session-Dedup** | Drops content repeated across turns (content-addressed, cross-turn) |
|
||||
| 2 | **CCR** | Archives large blocks behind retrieve markers, fetched on demand |
|
||||
| 3 | **RTK** | Smart tool-result filtering, dedup & truncation (command-aware) |
|
||||
| 4 | **Headroom** | Lossless tabular compaction of homogeneous JSON arrays (~30%+) |
|
||||
| 5 | **Caveman** | Rule-based prose compression (~65–75% on output) |
|
||||
| 6 | **LLMLingua-2** | ML semantic pruning via MobileBERT ONNX — code-safe, async |
|
||||
| 7 | **Lite** | Whitespace + image-URL trimming (latency-light baseline) |
|
||||
| 8 | **Aggressive** | Summarization + progressive aging of old turns |
|
||||
| 9 | **Ultra** | Heuristic token pruning with an optional small-model (SLM) tier |
|
||||
| # | Engine | What it does |
|
||||
| --- | ----------------- | ------------------------------------------------------------------- |
|
||||
| 1 | **Session-Dedup** | Drops content repeated across turns (content-addressed, cross-turn) |
|
||||
| 2 | **CCR** | Archives large blocks behind retrieve markers, fetched on demand |
|
||||
| 3 | **RTK** | Smart tool-result filtering, dedup & truncation (command-aware) |
|
||||
| 4 | **Headroom** | Lossless tabular compaction of homogeneous JSON arrays (~30%+) |
|
||||
| 5 | **Caveman** | Rule-based prose compression (~65–75% on output) |
|
||||
| 6 | **LLMLingua-2** | ML semantic pruning via MobileBERT ONNX — code-safe, async |
|
||||
| 7 | **Lite** | Whitespace + image-URL trimming (latency-light baseline) |
|
||||
| 8 | **Aggressive** | Summarization + progressive aging of old turns |
|
||||
| 9 | **Ultra** | Heuristic token pruning with an optional small-model (SLM) tier |
|
||||
|
||||
Code blocks, URLs and structured data are **always preserved** byte-perfect. **One-click presets** combine the engines:
|
||||
|
||||
@@ -500,7 +539,20 @@ average = 1 − (1 − 0.80) × (1 − 0.46) = 89.2%
|
||||
range = 78.4 – 94.6%
|
||||
```
|
||||
|
||||
Code blocks, URLs, JSON and structured data are **always protected** by the preservation engine. Auto-trigger compression by token threshold, or assign a compression pipeline per routing combo.
|
||||
Code blocks, URLs, JSON and structured data are **always protected** by the preservation engine.
|
||||
|
||||
### 🎚️ Beyond the engines — output styles, the adaptive dial & per-request control
|
||||
|
||||
The 9 engines above shrink what goes **in**. Three more layers shape **how**, **when**, and what comes **out**:
|
||||
|
||||
- **🪄 Output Styles** _(output-axis steering)_ — inject deterministic, cache-safe response-shaping instructions; combinable, each at `lite` / `full` / `ultra` intensity. Adding a style is a one-line registry entry:
|
||||
- **Terse prose** — drop filler / articles / hedging; keep technical substance exact.
|
||||
- **Less code** — "lazy senior dev" YAGNI: smallest working change, no unrequested scaffolding.
|
||||
- **Terse CJK (文言)** — classical-Chinese ultra-terse style (locale-gated to `zh`).
|
||||
- **🎯 Adaptive context-budget** _(the dial)_ — instead of one on/off token threshold, escalate the cheapest, most-lossless engines only as far as needed to **fit the model's context window**. Policy: `reserve-output` (default, model-aware) · `percentage` · `absolute`. Mode: `floor` (guarantee fit) · `replace-autotrigger` (your explicit choice wins) · `off` (legacy threshold).
|
||||
- **🎛️ Where compression is decided** _(precedence, high → low)_ — per-request `x-omniroute-compression` header › routing-combo override › active named profile › adaptive / auto-trigger › panel default › off. The applied plan echoes back in the `X-OmniRoute-Compression: <mode>; source=<source>` response header.
|
||||
|
||||
Auto-trigger by token threshold, flip on the adaptive dial, pin a named profile, set a one-off per request, or assign a pipeline per routing combo — whichever fits the workload. An opt-in offline **eval harness** (`npm run eval:compression`) scores fidelity vs. savings on a pinned corpus before you promote a change.
|
||||
|
||||
📖 [`COMPRESSION_GUIDE.md`](docs/compression/COMPRESSION_GUIDE.md) · [`RTK_COMPRESSION.md`](docs/compression/RTK_COMPRESSION.md) · [`COMPRESSION_ENGINES.md`](docs/compression/COMPRESSION_ENGINES.md)
|
||||
|
||||
@@ -523,7 +575,7 @@ Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`.
|
||||
|
||||
**2) Connect a FREE provider (no signup)**
|
||||
|
||||
Dashboard → **Providers** → connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → done.
|
||||
Dashboard → **Providers** → connect **Kiro AI** (free Claude, ~50 credits/month per account) or **OpenCode Free** (no auth) → done.
|
||||
|
||||
**3) Point your coding tool**
|
||||
|
||||
@@ -692,7 +744,7 @@ podman compose --profile base up -d
|
||||
**$0 forever:**
|
||||
|
||||
```
|
||||
1. kr/claude-sonnet-4.5 (Kiro — unlimited)
|
||||
1. kr/claude-sonnet-4.5 (Kiro — ~50 credits/mo per acct)
|
||||
2. if/kimi-k2-thinking (Qoder — unlimited)
|
||||
3. pol/gpt-5 (Pollinations — no key)
|
||||
4. lc/longcat-flash-lite (50M tok/day backup)
|
||||
@@ -749,9 +801,9 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
| `DATA_DIR` | `~/.omniroute` | Database & config storage |
|
||||
|
||||
**Will I be charged by OmniRoute?** No — it's free, open-source software on your machine. You only pay paid providers directly. OmniRoute has no billing system.
|
||||
**Are FREE providers really unlimited?** Yes — Kiro, Qoder, Pollinations, LongCat, Cloudflare. No catch.
|
||||
**Are FREE providers really unlimited?** Mostly — Qoder, Pollinations, LongCat, and Cloudflare are free with no per-account credit cap. Kiro is free too but capped at ~50 credits/month per account. Stack multiple free providers in a combo and auto-fallback keeps you serving for $0.
|
||||
**Will compression hurt quality?** No — it only compresses the **input**; code, URLs, JSON are always protected.
|
||||
**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 226 providers.
|
||||
**Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 231 providers.
|
||||
|
||||
📖 [User Guide](docs/guides/USER_GUIDE.md) · [API Reference](docs/reference/API_REFERENCE.md) · [Environment Config](docs/reference/ENVIRONMENT.md)
|
||||
|
||||
@@ -839,12 +891,14 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
|
||||
### 📘 Getting Started
|
||||
|
||||
| Document | Description |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment |
|
||||
| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
|
||||
| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
|
||||
| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure |
|
||||
| Document | Description |
|
||||
| -------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| [User Guide](docs/guides/USER_GUIDE.md) | Providers, combos, CLI integration, deployment |
|
||||
| [Setup Guide](docs/guides/SETUP_GUIDE.md) | Full install methods, CLI tool configs, protocol setup, timeout tuning |
|
||||
| [CLI Tools Guide](docs/reference/CLI-TOOLS.md) | Per-tool setup for Claude Code, Codex, Cursor, Cline, OpenClaw, Kilo, Copilot |
|
||||
| [Remote Mode](docs/guides/REMOTE-MODE.md) | Drive a remote OmniRoute (VPS) from your laptop CLI via scoped access tokens |
|
||||
| [Claude Code Config](docs/guides/CLAUDE-CODE-CONFIGURATION.md) | Point Claude Code at OmniRoute (local/remote) with `launch` + per-model profiles |
|
||||
| [Quick Start](README.md#-quick-start) | 3-step install → connect → configure |
|
||||
|
||||
### 🔧 Operations & Deployment
|
||||
|
||||
@@ -881,7 +935,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo
|
||||
| Document | Description |
|
||||
| ------------------------------------------------- | --------------------------------------------------- |
|
||||
| [API Reference](docs/reference/API_REFERENCE.md) | All endpoints with examples |
|
||||
| [OpenAPI Spec](docs/reference/openapi.yaml) | OpenAPI 3.0 specification |
|
||||
| [OpenAPI Spec](docs/openapi.yaml) | OpenAPI 3.0 specification |
|
||||
| [MCP Server](open-sse/mcp-server/README.md) | 87 MCP tools, IDE configs, Python/TS/Go clients |
|
||||
| [MCP Server Guide](docs/frameworks/MCP-SERVER.md) | MCP installation, transports, and tool reference |
|
||||
| [A2A Server](src/lib/a2a/README.md) | JSON-RPC 2.0 protocol, skills, streaming, task mgmt |
|
||||
@@ -1022,31 +1076,78 @@ gh release create v3.8.2 --title "v3.8.2" --generate-notes
|
||||
|
||||
</div>
|
||||
|
||||
Special thanks to **[9router](https://github.com/decolua/9router)** by **[decolua](https://github.com/decolua)** — the original project that inspired this fork. OmniRoute builds upon that incredible foundation with additional features, multi-modal APIs, and a full TypeScript rewrite.
|
||||
OmniRoute stands on the shoulders of giants. It started as a fork of **[9router](https://github.com/decolua/9router)** and a TypeScript port of the Go project **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — and from there, every subsystem below was inspired by an open-source project that got there first. Each one shaped a concrete piece of OmniRoute. This is our thank-you to all of them. 🙏
|
||||
|
||||
Special thanks to **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** by **[router-for-me](https://github.com/router-for-me)** — the original Go implementation that inspired this JavaScript port.
|
||||
> ⭐ star counts as of June 2026 — go give these projects a star.
|
||||
|
||||
Special thanks to **[Caveman](https://github.com/JuliusBrussee/caveman)** by **[JuliusBrussee](https://github.com/JuliusBrussee)** (⭐ 51K+) — the viral "why use many token when few token do trick" project whose caveman-speak compression philosophy inspired OmniRoute's standard compression mode and 30+ filler/condensation regex rules.
|
||||
### 🧬 Lineage & gateway
|
||||
|
||||
Special thanks to **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** by **[RTK AI](https://github.com/rtk-ai)** — the high-performance command-output compression project whose terminal, build, test, git, and tool-output filtering model inspired OmniRoute's RTK engine, JSON filter DSL, raw-output recovery, and stacked RTK → Caveman compression pipeline.
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ------------------------------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[9router](https://github.com/decolua/9router)** · decolua | 17.9k | The original project this fork is built on — extended here with multi-modal APIs and a full TypeScript rewrite. |
|
||||
| **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** · router-for-me | 37.8k | The Go implementation that inspired this JavaScript / TypeScript port. |
|
||||
| **[LiteLLM](https://github.com/BerriAI/litellm)** · BerriAI | 50.8k | The AI gateway whose public pricing dataset feeds our cost-tracking sync and whose provider-normalization model informed our routing. |
|
||||
|
||||
Special thanks to **[Troglodita](https://github.com/leninejunior/troglodita)** by **[Lenine Júnior](https://github.com/leninejunior)** — the PT-BR token compression project ("por que gastar muitos tokens quando poucos resolve?") whose Portuguese-native rules power OmniRoute's pt-BR language pack: pleonasm reduction, filler removal tuned for Brazilian Portuguese grammar, and technical abbreviations for the dev BR community.
|
||||
### 🗜️ Context & token compression — engines
|
||||
|
||||
Special thanks to **[headroom](https://github.com/chopratejas/headroom)** by **[chopratejas](https://github.com/chopratejas)** — the reversible context-compression project whose SmartCrusher (per-type routing, reversible block compaction, internal hash cache) directly inspired OmniRoute's `headroom` engine and the `ccr` retrieve-marker pattern.
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ---------------------------------------------------------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[Caveman](https://github.com/JuliusBrussee/caveman)** · JuliusBrussee | 74.5k | The viral "why use many token when few token do trick" project — its caveman-speak philosophy powers our standard compression mode and 30+ filler/condensation rules. |
|
||||
| **[RTK – Rust Token Killer](https://github.com/rtk-ai/rtk)** · rtk-ai | 63.6k | High-performance command-output compression — inspired our RTK engine, JSON filter DSL, raw-output recovery and the stacked RTK → Caveman pipeline. |
|
||||
| **[headroom](https://github.com/chopratejas/headroom)** · chopratejas | 33.6k | Reversible context-compression (SmartCrusher) — inspired our `headroom` engine and the `ccr` retrieve-marker pattern. |
|
||||
| **[LLMLingua](https://github.com/microsoft/LLMLingua)** · Microsoft | 6.3k | Prompt-compression research (LLMLingua / LLMLingua-2) — inspired our async, code-safe, fail-open `llmlingua` engine. |
|
||||
| **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** · atjsh | 27 | The JS/ONNX port (MobileBERT / XLM-RoBERTa) used as the worker-thread backend for our LLMLingua engine. |
|
||||
| **[Troglodita](https://github.com/leninejunior/troglodita)** · Lenine Júnior | 15 | PT-BR token compression — powers our pt-BR language pack: pleonasm reduction and filler removal tuned for Brazilian-Portuguese grammar. |
|
||||
| **[ponytail](https://github.com/DietrichGebert/ponytail)** · DietrichGebert | 51.4k | The viral "lazy senior dev" YAGNI-coder skill — inspired our **less-code** Output Style: smallest-working-change steering that cuts _generated_ code (the output-axis sibling to Caveman's terse prose). |
|
||||
|
||||
Special thanks to **[TOON](https://github.com/toon-format/toon)** by **[toon-format](https://github.com/toon-format)** and **[GCF — Graph Compact Format](https://github.com/blackwell-systems/gcf)** by **[Dayna Blackwell / Blackwell Systems](https://github.com/blackwell-systems)** — the compact, schema-aware "JSON for LLMs" notations whose columnar, header-plus-rows model shaped OmniRoute's `headroom`/SmartCrusher tabular stage: a dependency-free, lossless compaction of homogeneous JSON arrays with an explicit `[N rows]` marker.
|
||||
### 🧩 Compact formats, token research & code-aware tooling
|
||||
|
||||
Special thanks to **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** by **[ooples](https://github.com/ooples)** — the Brotli/SQLite cache + per-session context-delta project whose content-addressed delta model inspired OmniRoute's `session-dedup` engine (cross-turn block deduplication with reversible references).
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ---------------------------------------------------------------------------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[TOON](https://github.com/toon-format/toon)** · toon-format | 24.6k | Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage. |
|
||||
| **[GCF – Graph Compact Format](https://github.com/blackwell-systems/gcf)** · Blackwell Systems | 11 | Schema-aware "JSON for LLMs" notation — co-inspired our lossless homogeneous-array compaction with `[N rows]` markers. |
|
||||
| **[token-optimizer-mcp](https://github.com/ooples/token-optimizer-mcp)** · ooples | 409 | Brotli/SQLite cache + per-session context-delta — inspired our `session-dedup` engine. |
|
||||
| **[token-savior](https://github.com/Mibayy/token-savior)** · Mibayy | 993 | Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction. |
|
||||
| **[token-saver](https://github.com/ppgranger/token-saver)** · ppgranger | 103 | Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip. |
|
||||
| **[token-optimizer](https://github.com/alexgreensh/token-optimizer)** · alexgreensh | 1.4k | "Find the ghost tokens" — its offload + recoverable-handle pattern informed our CCR offload thinking. |
|
||||
| **[TokenMizer](https://github.com/Shweta-Mishra-ai/tokenmizer)** · Shweta-Mishra-ai | 1 | A session-graph + cross-turn line-dedup blueprint that informed our session-dedup design. |
|
||||
| **[OmniCompress](https://github.com/jessefreitas/OmniCompress)** · jessefreitas | 2 | Rust columnar-JSON + content-addressed retrieve + cross-message dedup — validated our `headroom`/`ccr`/`session-dedup` engine design and the cache-stable "compressed form is position-independent" invariant. |
|
||||
| **[mcp-compressor](https://github.com/atlassian-labs/mcp-compressor)** · Atlassian Labs | 80 | MCP tool-schema/description compression — informed our MCP tool-manifest cardinality reduction. |
|
||||
| **[RepoMapper](https://github.com/pdavis68/RepoMapper)** · pdavis68 | 182 | Aider-style repo-map ranking — informed our repo-map / retrieval-ranking exploration. |
|
||||
| **[quiet-shell-mcp](https://github.com/mrsimpson/quiet-shell-mcp)** · mrsimpson | 4 | Declarative shell-output reduction over MCP — validated our declarative bash-output compaction. |
|
||||
| **[ts-morph](https://github.com/dsherret/ts-morph)** · David Sherret | 6.1k | TypeScript Compiler API toolkit — inspired our parser-based comment removal that preserves string, template and regex literals. |
|
||||
|
||||
Special thanks to **[token-savior](https://github.com/Mibayy/token-savior)** by **[Mibayy](https://github.com/Mibayy)** — the Bash-output compaction + MCP-profiles project whose failure-aware bail-out and tool-profile model inspired OmniRoute's compression bail-out discipline and MCP tool-manifest cardinality reduction.
|
||||
### 🧠 Memory & RAG
|
||||
|
||||
Special thanks to **[LLMLingua](https://github.com/microsoft/LLMLingua)** by **[Microsoft](https://github.com/microsoft)** — the prompt-compression research (LLMLingua / LLMLingua-2) whose token-level semantic pruning inspired OmniRoute's async `llmlingua` engine (prose-only, code-safe, fail-open), together with the JS/ONNX port **[llmlingua-2-js](https://github.com/atjsh/llmlingua-2-js)** by **[atjsh](https://github.com/atjsh)** (MobileBERT / XLM-RoBERTa ONNX models) as its intended worker-thread backend.
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ------------------------------------------------------------------ | ----: | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[Mem0](https://github.com/mem0ai/mem0)** · mem0ai | 58.9k | Universal memory layer — its proxy-as-write/read-boundary model shaped our memory architecture. |
|
||||
| **[Letta (MemGPT)](https://github.com/letta-ai/letta)** · letta-ai | 23.4k | Stateful agents with tiered memory — inspired our Context Control & Recovery (CCR) tiered model. |
|
||||
| **[WFGY](https://github.com/onestardao/WFGY)** · onestardao | 1.8k | The ProblemMap taxonomy of 16 recurring RAG/LLM failure modes — the shared vocabulary in our troubleshooting guide. |
|
||||
|
||||
Special thanks to **[ts-morph](https://github.com/dsherret/ts-morph)** by **[David Sherret](https://github.com/dsherret)** — the TypeScript Compiler API toolkit whose AST approach inspired OmniRoute's parser-based code-comment removal, which correctly preserves string, template, and regex literals where naïve regex stripping corrupts them.
|
||||
### 🛰️ Traffic inspection, MITM & transparent proxy
|
||||
|
||||
Special thanks to **[React Flow / xyflow](https://github.com/xyflow/xyflow)** by **[xyflow](https://github.com/xyflow)** — the node-based graph library that powers OmniRoute's real-time **Compression Studio** and **Combo/Routing Studio** dashboards.
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| --------------------------------------------------------------------------------- | ---: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[llm-interceptor](https://github.com/chouzz/llm-interceptor)** · chouzz | 46 | MITM interception/analysis of coding-assistant ↔ LLM traffic — our Traffic Inspector ports its SSE merge, conversation normalization, host passthrough and secret masking (MIT). |
|
||||
| **[ProxyBridge](https://github.com/InterceptSuite/ProxyBridge)** · InterceptSuite | 5.1k | Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, `/proc` process attribution and TPROXY capture. |
|
||||
|
||||
Special thanks to **[LangGraph](https://github.com/langchain-ai/langgraph)** by **[LangChain](https://github.com/langchain-ai)** — the agent-graph framework whose LangGraph Studio live workflow-graph visualization inspired OmniRoute's Compression and Combo Studios: watching compression engines and combo fallbacks cascade in real time.
|
||||
### 📚 Model data, observability & UI
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| -------------------------------------------------------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **[models.dev](https://github.com/anomalyco/models.dev)** · SST / OpenCode | 5.1k | Open database of AI model specs, pricing and capabilities — synced natively into our model catalog. |
|
||||
| **[React Flow / xyflow](https://github.com/xyflow/xyflow)** · xyflow | 37.1k | The node-based graph library powering our real-time Compression Studio and Combo/Routing Studio. |
|
||||
| **[LangGraph](https://github.com/langchain-ai/langgraph)** · LangChain | 35.1k | LangGraph Studio's live workflow-graph visualization inspired our Studios' real-time cascade view. |
|
||||
| **[Langfuse](https://github.com/langfuse/langfuse)** · Langfuse | 29.3k | Its trace → span → generation observability model shaped our Compression Studio waterfall. |
|
||||
| **[Kiali](https://github.com/kiali/kiali)** · Kiali | 3.6k | Istio service-mesh observability — inspired our circuit-breaker badges and error-edge visuals in the Routing/Combo Studio. |
|
||||
| **[lobe-icons](https://github.com/lobehub/lobe-icons)** · LobeHub | 2.1k | AI/LLM brand logos that render the provider icons across our dashboard. |
|
||||
|
||||
### 🛡️ Security
|
||||
|
||||
| Project | ⭐ | How it inspired OmniRoute |
|
||||
| ------------------------------------------------------------------------------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **[awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)** · tldrsec | 708 | A curated list of secure-by-default libraries that guides our security choices (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink). |
|
||||
|
||||
## ❤️ Support
|
||||
|
||||
|
||||
70
bin/_ops-common.sh
Normal file
70
bin/_ops-common.sh
Normal file
@@ -0,0 +1,70 @@
|
||||
# bin/_ops-common.sh — shared helpers for the OmniRoute ops runbook scripts.
|
||||
#
|
||||
# Sourced (not executed) by rollback.sh / snapshot-data.sh / restore-data.sh /
|
||||
# restore-policies.sh / cold-start-bench.sh. The runbook context lives in
|
||||
# docs/INCIDENT_RESPONSE.md and docs/PERF_BUDGETS.md.
|
||||
#
|
||||
# Path resolution mirrors the app (src/lib/db/core.ts): the SQLite store is
|
||||
# $DATA_DIR/storage.sqlite and managed backups go to $DATA_DIR/db_backups
|
||||
# (overridable via DB_BACKUPS_DIR), so snapshots created here are interchangeable
|
||||
# with the ones the server writes on migrations.
|
||||
|
||||
# Recompute the data-dir-derived paths. Called once on source, and again by
|
||||
# scripts that accept a --data-dir override.
|
||||
ops_set_data_dir() {
|
||||
OMNIROUTE_DATA_DIR="$1"
|
||||
OMNIROUTE_SQLITE="${OMNIROUTE_DATA_DIR}/storage.sqlite"
|
||||
OMNIROUTE_BACKUPS_DIR="${DB_BACKUPS_DIR:-${OMNIROUTE_DATA_DIR}/db_backups}"
|
||||
}
|
||||
ops_set_data_dir "${DATA_DIR:-$HOME/.omniroute}"
|
||||
|
||||
ops_log() { printf '[%s] %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2; }
|
||||
ops_die() {
|
||||
printf '[%s] ERROR: %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ops_require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || ops_die "required command not found: $1"
|
||||
}
|
||||
|
||||
# ops_confirm "<prompt>" — return 0 to proceed. Honors ASSUME_YES=1 (set by the
|
||||
# --yes flag) and REFUSES a destructive action on a non-interactive stdin unless
|
||||
# ASSUME_YES is set, so an unattended/CI invocation can never silently destroy data.
|
||||
ops_confirm() {
|
||||
local prompt="$1" reply
|
||||
if [ "${ASSUME_YES:-0}" = "1" ]; then return 0; fi
|
||||
if [ ! -t 0 ]; then
|
||||
ops_die "refusing a destructive action without a TTY; pass --yes to proceed non-interactively"
|
||||
fi
|
||||
read -r -p "$prompt [y/N] " reply
|
||||
case "$reply" in
|
||||
[yY] | [yY][eE][sS]) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ops_find_snapshot <id> — resolve a snapshot identifier (a snapshot dir name,
|
||||
# a bare timestamp/sha, or an explicit path) to a directory containing
|
||||
# storage.sqlite. Echoes the resolved dir or dies.
|
||||
ops_find_snapshot() {
|
||||
local id="$1" cand
|
||||
[ -n "$id" ] || ops_die "snapshot id required (a timestamp/sha, dir name, or path)"
|
||||
for cand in \
|
||||
"$id" \
|
||||
"$id/" \
|
||||
"$OMNIROUTE_BACKUPS_DIR/$id" \
|
||||
"$OMNIROUTE_BACKUPS_DIR/snapshot_$id"; do
|
||||
if [ -f "${cand%/}/storage.sqlite" ]; then
|
||||
printf '%s\n' "${cand%/}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fall back to a prefix match against snapshot_* dirs (e.g. a short sha/date).
|
||||
if [ -d "$OMNIROUTE_BACKUPS_DIR" ]; then
|
||||
for cand in "$OMNIROUTE_BACKUPS_DIR"/snapshot_*"$id"*; do
|
||||
[ -f "$cand/storage.sqlite" ] && { printf '%s\n' "$cand"; return 0; }
|
||||
done
|
||||
fi
|
||||
ops_die "no snapshot matching '$id' under $OMNIROUTE_BACKUPS_DIR (run bin/snapshot-data.sh first)"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit.
|
||||
// AUTO-GENERATED from docs/openapi.yaml. Do not edit.
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDataDir } from "./data-dir.mjs";
|
||||
import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs";
|
||||
import { resolveActiveContext } from "./contexts.mjs";
|
||||
|
||||
export const RETRY_DEFAULTS = Object.freeze({
|
||||
maxAttempts: 3,
|
||||
@@ -28,14 +26,12 @@ export function getBaseUrl(opts = {}) {
|
||||
const envUrl = process.env.OMNIROUTE_BASE_URL;
|
||||
if (envUrl) return stripTrailingSlash(envUrl);
|
||||
|
||||
// Resolve from the active context (canonical store + legacy profile fallback).
|
||||
// This is what makes "remote mode" work: `omniroute contexts use <remote>`
|
||||
// routes every command at the remote server's baseUrl.
|
||||
try {
|
||||
const configPath = join(resolveDataDir(), "config.json");
|
||||
if (existsSync(configPath)) {
|
||||
const cfg = JSON.parse(readFileSync(configPath, "utf8"));
|
||||
const profile = cfg.activeProfile && cfg.profiles?.[cfg.activeProfile];
|
||||
if (profile?.baseUrl) return stripTrailingSlash(profile.baseUrl);
|
||||
if (cfg.baseUrl) return stripTrailingSlash(cfg.baseUrl);
|
||||
}
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
if (ctx?.baseUrl) return stripTrailingSlash(ctx.baseUrl);
|
||||
} catch {
|
||||
// Config read failures are not fatal — fall through to default.
|
||||
}
|
||||
@@ -56,15 +52,40 @@ function resolveUrl(path, opts) {
|
||||
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
async function buildHeaders(opts) {
|
||||
export async function buildHeaders(opts) {
|
||||
const headers = new Headers(opts.headers || {});
|
||||
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||
if (opts.body && !headers.has("content-type") && typeof opts.body !== "string") {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
const apiKey = opts.apiKey ?? process.env.OMNIROUTE_API_KEY;
|
||||
if (apiKey && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${apiKey}`);
|
||||
// Auth precedence: explicit key → active-context credential → ambient env key.
|
||||
//
|
||||
// The active context's scoped token MUST win over the ambient OMNIROUTE_API_KEY:
|
||||
// `omniroute connect <remote>` saves the context's token, but users keep
|
||||
// OMNIROUTE_API_KEY in their shell. The global `--api-key` option is bound to
|
||||
// that env var (.env("OMNIROUTE_API_KEY")), so commands that spread
|
||||
// `optsWithGlobals()` into apiFetch carry opts.apiKey === the env value. If that
|
||||
// echoed value outranked the context, every remote management command would send
|
||||
// the local inference key and fail with "Invalid management token" — defeating
|
||||
// remote mode. So an opts.apiKey that merely mirrors the ambient env var is
|
||||
// treated as ambient (a fallback), NOT as an explicit override; only a DISTINCT
|
||||
// key — a real `--api-key <x>` flag or a command-supplied token like
|
||||
// `connect --key` — counts as explicit and wins. Within a context the scoped
|
||||
// accessToken wins over the legacy apiKey.
|
||||
const ambientKey = process.env.OMNIROUTE_API_KEY || null;
|
||||
const explicitKey = opts.apiKey && opts.apiKey !== ambientKey ? opts.apiKey : null;
|
||||
let auth = explicitKey;
|
||||
if (!auth) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
auth = ctx?.accessToken || ctx?.apiKey || null;
|
||||
} catch {
|
||||
// No context credential available — fall through to the ambient fallback.
|
||||
}
|
||||
}
|
||||
if (!auth) auth = opts.apiKey || ambientKey || null;
|
||||
if (auth && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${auth}`);
|
||||
}
|
||||
// Inject machine-id derived CLI token; env var override for testing.
|
||||
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
|
||||
|
||||
180
bin/cli/commands/configure.mjs
Normal file
180
bin/cli/commands/configure.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute configure <cli>` — interactive provider+model picker that writes a
|
||||
* local CLI config pointed at the ACTIVE OmniRoute context (local or remote).
|
||||
*
|
||||
* The model catalog comes from the active context's GET /v1/models, so when you
|
||||
* are in remote mode (`omniroute connect ...`) you pick from the remote server's
|
||||
* live models and the profile is written on THIS machine.
|
||||
*
|
||||
* v1 targets the Codex CLI (writes ~/.codex/<name>.config.toml). The credential
|
||||
* is referenced by env var (OMNIROUTE_API_KEY) — never written to disk.
|
||||
*/
|
||||
|
||||
const SUPPORTED = ["codex"];
|
||||
|
||||
/** Derive a short, filesystem-safe profile name from a model id. */
|
||||
export function profileNameFromModel(modelId) {
|
||||
const afterProvider = String(modelId).includes("/")
|
||||
? String(modelId).split("/").slice(1).join("/")
|
||||
: String(modelId);
|
||||
return afterProvider.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "model";
|
||||
}
|
||||
|
||||
/** Provider id for a catalog entry: explicit owned_by, else the id prefix. */
|
||||
function providerOf(entry) {
|
||||
if (entry && typeof entry.owned_by === "string" && entry.owned_by) return entry.owned_by;
|
||||
const id = typeof entry === "string" ? entry : entry?.id || "";
|
||||
return id.includes("/") ? id.split("/")[0] : "(none)";
|
||||
}
|
||||
|
||||
function contextWindowOf(entry) {
|
||||
for (const c of [entry?.context_length, entry?.max_context_window_tokens]) {
|
||||
if (typeof c === "number" && Number.isFinite(c) && c > 0) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchModels(globalOpts) {
|
||||
const res = await apiFetch("/v1/models", { ...globalOpts, acceptNotOk: true });
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const b = await res.json();
|
||||
msg = b?.error?.message || b?.error || msg;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(`Could not fetch models: ${msg}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data || body.models || [];
|
||||
return list.filter((m) => (typeof m === "string" ? m : m?.id));
|
||||
}
|
||||
|
||||
function buildCodexProfile(modelId, ctx) {
|
||||
const lines = [
|
||||
`# codex --profile ${profileNameFromModel(modelId)}`,
|
||||
`# ${modelId} — generated by 'omniroute configure codex'`,
|
||||
`model = "${modelId}"`,
|
||||
`model_provider = "omniroute"`,
|
||||
];
|
||||
if (ctx && ctx > 0) {
|
||||
const compact = Math.floor(ctx * 0.85);
|
||||
lines.push(`model_context_window = ${ctx}`);
|
||||
lines.push(`model_auto_compact_token_limit = ${compact}`);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
async function configureCodex(modelId, ctxWindow, opts) {
|
||||
const codexHome = opts.codexHome || path.join(os.homedir(), ".codex");
|
||||
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
|
||||
const profile = opts.name || profileNameFromModel(modelId);
|
||||
const filePath = path.join(codexHome, `${profile}.config.toml`);
|
||||
if (existsSync(filePath)) {
|
||||
copyFileSync(filePath, `${filePath}.bak`);
|
||||
}
|
||||
writeFileSync(filePath, buildCodexProfile(modelId, ctxWindow), "utf8");
|
||||
printSuccess(`Wrote ${filePath}`);
|
||||
printInfo(`Use it: codex --profile ${profile}`);
|
||||
printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block");
|
||||
printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md).");
|
||||
}
|
||||
|
||||
export async function runConfigureCommand(cli, opts = {}, cmd) {
|
||||
const target = String(cli || "").toLowerCase();
|
||||
if (!SUPPORTED.includes(target)) {
|
||||
printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`);
|
||||
return 2;
|
||||
}
|
||||
const globalOpts = cmd ? cmd.optsWithGlobals() : {};
|
||||
|
||||
let models;
|
||||
try {
|
||||
models = await fetchModels(globalOpts);
|
||||
} catch (e) {
|
||||
printError(e instanceof Error ? e.message : String(e));
|
||||
return 1;
|
||||
}
|
||||
if (!models.length) {
|
||||
printError("The server returned no models.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Resolve model: explicit flags or interactive pick.
|
||||
let chosenId = opts.model;
|
||||
if (chosenId && opts.provider && !chosenId.includes("/")) {
|
||||
chosenId = `${opts.provider}/${chosenId}`;
|
||||
}
|
||||
|
||||
if (!chosenId) {
|
||||
const ids = models.map((m) => (typeof m === "string" ? m : m.id));
|
||||
const providers = [...new Set(models.map(providerOf))].sort();
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
printHeading("Configure Codex CLI");
|
||||
let providerList = providers;
|
||||
if (opts.provider) {
|
||||
providerList = providers.filter((p) => p === opts.provider);
|
||||
} else {
|
||||
printInfo(`Providers: ${providers.join(", ")}`);
|
||||
const p = await prompt.ask("Provider");
|
||||
if (p) providerList = providers.filter((x) => x === p);
|
||||
}
|
||||
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
|
||||
const candidates = inProvider.length ? inProvider : ids;
|
||||
printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`);
|
||||
chosenId = await prompt.ask("Model id");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (!chosenId) {
|
||||
printError("No model selected.");
|
||||
return 2;
|
||||
}
|
||||
const entry = byId(models, chosenId);
|
||||
if (!entry) {
|
||||
printError(`Model '${chosenId}' is not in the catalog.`);
|
||||
return 2;
|
||||
}
|
||||
const ctxWindow = contextWindowOf(entry);
|
||||
|
||||
if (target === "codex") {
|
||||
await configureCodex(chosenId, ctxWindow, opts);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function byId(models, id) {
|
||||
for (const m of models) {
|
||||
const mid = typeof m === "string" ? m : m.id;
|
||||
if (mid === id) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerConfigure(program) {
|
||||
program
|
||||
.command("configure <cli>")
|
||||
.description(
|
||||
t("configure.description") ||
|
||||
"Pick a provider+model from the active server and write a local CLI config (v1: codex)"
|
||||
)
|
||||
.option("--provider <id>", "Provider id (skips the interactive provider prompt)")
|
||||
.option("--model <id>", "Model id (skips the interactive model prompt)")
|
||||
.option("--name <name>", "Profile name to write (default: derived from model)")
|
||||
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
|
||||
.action(async (cli, opts, cmd) => {
|
||||
const code = await runConfigureCommand(cli, opts, cmd);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
132
bin/cli/commands/connect.mjs
Normal file
132
bin/cli/commands/connect.mjs
Normal file
@@ -0,0 +1,132 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { loadContexts, saveContexts } from "../contexts.mjs";
|
||||
import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute connect <host>` — remote mode.
|
||||
*
|
||||
* Logs into a remote OmniRoute server and saves the result as the active context
|
||||
* so every subsequent command targets that server. Two flows:
|
||||
* - password: prompts for the management password → POST /api/cli/connect →
|
||||
* server mints a scoped access token (default scope: admin).
|
||||
* - token: `--key <oma_...>` validates via GET /api/cli/whoami and saves it.
|
||||
*/
|
||||
|
||||
/** Normalize a host/URL into a server root baseUrl (no trailing path). */
|
||||
export function normalizeBaseUrl(host, port) {
|
||||
let value = String(host || "").trim();
|
||||
if (!value) return "";
|
||||
const hadScheme = /^https?:\/\//i.test(value);
|
||||
if (!hadScheme) value = `http://${value}`;
|
||||
try {
|
||||
const u = new URL(value);
|
||||
// Only apply the default port to a bare host; a full URL is taken as-is.
|
||||
if (!hadScheme && !u.port && port) u.port = String(port);
|
||||
return u.origin;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive a clean context name from a host (strip scheme/port). */
|
||||
export function hostLabel(host) {
|
||||
let value = String(host || "").trim().replace(/^https?:\/\//i, "");
|
||||
value = value.split("/")[0].split(":")[0];
|
||||
return value || "remote";
|
||||
}
|
||||
|
||||
async function readErrorMessage(res) {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.error?.message || body?.error || `HTTP ${res.status}`;
|
||||
} catch {
|
||||
return `HTTP ${res.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runConnectCommand(host, opts = {}) {
|
||||
const baseUrl = normalizeBaseUrl(host, opts.port || "20128");
|
||||
if (!baseUrl) {
|
||||
printError("A host is required, e.g. omniroute connect 192.168.0.15");
|
||||
return 2;
|
||||
}
|
||||
const name = opts.name || hostLabel(host);
|
||||
|
||||
let accessToken;
|
||||
let scope;
|
||||
|
||||
if (opts.key) {
|
||||
// Validate the pasted token against the remote.
|
||||
const res = await apiFetch("/api/cli/whoami", {
|
||||
baseUrl,
|
||||
apiKey: opts.key,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Token rejected by ${baseUrl}: ${await readErrorMessage(res)}`);
|
||||
return res.exitCode || 1;
|
||||
}
|
||||
const body = await res.json();
|
||||
accessToken = opts.key;
|
||||
scope = body.scope || "unknown";
|
||||
} else {
|
||||
const prompt = createPrompt();
|
||||
let password;
|
||||
try {
|
||||
password = await prompt.askSecret(`Management password for ${baseUrl}`);
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
if (!password) {
|
||||
printError("Password is required (or use --key <token>).");
|
||||
return 2;
|
||||
}
|
||||
const res = await apiFetch("/api/cli/connect", {
|
||||
baseUrl,
|
||||
method: "POST",
|
||||
body: { password, name, scope: opts.scope },
|
||||
acceptNotOk: true,
|
||||
retry: false,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Connect failed (${res.status}): ${await readErrorMessage(res)}`);
|
||||
return res.exitCode || 1;
|
||||
}
|
||||
const body = await res.json();
|
||||
accessToken = body.token;
|
||||
scope = body.scope;
|
||||
}
|
||||
|
||||
const cfg = loadContexts();
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl,
|
||||
accessToken,
|
||||
scope,
|
||||
description: `Remote OmniRoute (${host})`,
|
||||
};
|
||||
cfg.currentContext = name;
|
||||
saveContexts(cfg);
|
||||
|
||||
printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`);
|
||||
printInfo("All commands now target this server.");
|
||||
printInfo("Switch back to local with: omniroute contexts use default");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerConnect(program) {
|
||||
program
|
||||
.command("connect <host>")
|
||||
.description(
|
||||
t("connect.description") || "Connect to a remote OmniRoute server and enter remote mode"
|
||||
)
|
||||
.option("--port <port>", "Server port when the host has none", "20128")
|
||||
.option("--key <token>", "Use a pre-generated scoped access token (skips the password prompt)")
|
||||
.option("--name <name>", "Context name to save (default: derived from host)")
|
||||
.option("--scope <scope>", "Requested scope for the password flow (read|write|admin)")
|
||||
.action(async (host, opts) => {
|
||||
const code = await runConnectCommand(host, opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
import { t } from "../i18n.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { loadContexts, saveContexts, configPath } from "../contexts.mjs";
|
||||
import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
async function confirm(msg) {
|
||||
/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */
|
||||
function authLabel(c) {
|
||||
if (c?.accessToken) return "token";
|
||||
if (c?.apiKey) return "key";
|
||||
return "✗";
|
||||
}
|
||||
|
||||
export async function confirm(msg) {
|
||||
// Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking
|
||||
// anyway leaves the readline question pending forever — Node then warns about an
|
||||
// "unsettled top-level await" at exit. Decline cleanly instead and point at the
|
||||
// non-interactive escape hatch so scripted callers fail safe rather than hang.
|
||||
if (!process.stdin.isTTY) {
|
||||
process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`);
|
||||
return false;
|
||||
}
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((r) => rl.question(`${msg} [y/N] `, r));
|
||||
@@ -19,6 +34,7 @@ function maskKey(k) {
|
||||
export function registerContexts(program) {
|
||||
const ctx = program
|
||||
.command("contexts")
|
||||
.alias("context") // singular alias — docs/connect output historically said `context current`
|
||||
.description(t("config.contexts.description") || "Manage server contexts/profiles");
|
||||
|
||||
ctx
|
||||
@@ -31,7 +47,8 @@ export function registerContexts(program) {
|
||||
active: name === (cfg.currentContext || "default") ? "●" : "",
|
||||
name,
|
||||
baseUrl: c.baseUrl || "",
|
||||
auth: c.apiKey ? "✓" : "✗",
|
||||
auth: authLabel(c),
|
||||
scope: c.scope || "",
|
||||
description: c.description || "",
|
||||
}));
|
||||
emit(rows, globalOpts, [
|
||||
@@ -39,6 +56,7 @@ export function registerContexts(program) {
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "baseUrl", header: "Base URL" },
|
||||
{ key: "auth", header: "Auth" },
|
||||
{ key: "scope", header: "Scope" },
|
||||
{ key: "description", header: "Description" },
|
||||
]);
|
||||
});
|
||||
@@ -47,8 +65,11 @@ export function registerContexts(program) {
|
||||
.command("add <name>")
|
||||
.description("Add a new context")
|
||||
.requiredOption("--url <u>", "Base URL")
|
||||
.option("--api-key <k>", "API key")
|
||||
.option("--api-key <k>", "Legacy inference API key")
|
||||
.option("--api-key-stdin", "Read API key from stdin")
|
||||
.option("--access-token <t>", "Scoped CLI access token (preferred over --api-key)")
|
||||
.option("--access-token-stdin", "Read access token from stdin")
|
||||
.option("--scope <s>", "Token scope hint for display (read|write|admin)")
|
||||
.option("--description <d>", "Context description")
|
||||
.action(async (name, opts) => {
|
||||
const cfg = loadContexts();
|
||||
@@ -57,15 +78,20 @@ export function registerContexts(program) {
|
||||
process.exit(2);
|
||||
}
|
||||
let apiKey = opts.apiKey || null;
|
||||
if (opts.apiKeyStdin) {
|
||||
let accessToken = opts.accessToken || null;
|
||||
if (opts.apiKeyStdin || opts.accessTokenStdin) {
|
||||
const chunks = [];
|
||||
for await (const c of process.stdin) chunks.push(c);
|
||||
apiKey = chunks.join("").trim() || null;
|
||||
const value = chunks.join("").trim() || null;
|
||||
if (opts.accessTokenStdin) accessToken = value;
|
||||
else apiKey = value;
|
||||
}
|
||||
cfg.contexts = cfg.contexts || {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl: opts.url,
|
||||
accessToken: accessToken || undefined,
|
||||
apiKey,
|
||||
scope: opts.scope || undefined,
|
||||
description: opts.description || undefined,
|
||||
};
|
||||
saveContexts(cfg);
|
||||
@@ -88,10 +114,27 @@ export function registerContexts(program) {
|
||||
|
||||
ctx
|
||||
.command("current")
|
||||
.description("Show current active context name")
|
||||
.action(() => {
|
||||
.description("Show the active context (server, auth, scope)")
|
||||
.option("--name-only", "Print just the context name (legacy behavior)")
|
||||
.action((opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const cfg = loadContexts();
|
||||
process.stdout.write(`${cfg.currentContext || "default"}\n`);
|
||||
const name = cfg.currentContext || cfg.activeProfile || "default";
|
||||
if (opts.nameOnly) {
|
||||
process.stdout.write(`${name}\n`);
|
||||
return;
|
||||
}
|
||||
const c = resolveActiveContext(name);
|
||||
emit(
|
||||
{
|
||||
name,
|
||||
baseUrl: c.baseUrl || "",
|
||||
auth: authLabel(c),
|
||||
scope: c.scope || "",
|
||||
description: c.description || "",
|
||||
},
|
||||
globalOpts
|
||||
);
|
||||
});
|
||||
|
||||
ctx
|
||||
@@ -108,7 +151,9 @@ export function registerContexts(program) {
|
||||
const display = {
|
||||
name,
|
||||
baseUrl: c.baseUrl,
|
||||
accessToken: maskKey(c.accessToken),
|
||||
apiKey: maskKey(c.apiKey),
|
||||
scope: c.scope,
|
||||
description: c.description,
|
||||
};
|
||||
emit(display, globalOpts);
|
||||
@@ -172,6 +217,7 @@ export function registerContexts(program) {
|
||||
if (opts.noSecrets) {
|
||||
for (const c of Object.values(out.contexts || {})) {
|
||||
c.apiKey = null;
|
||||
delete c.accessToken;
|
||||
}
|
||||
}
|
||||
const json = JSON.stringify(out, null, 2);
|
||||
@@ -209,7 +255,9 @@ export function registerContexts(program) {
|
||||
const c = raw && typeof raw === "object" ? /** @type {Record<string,unknown>} */ (raw) : {};
|
||||
cfg.contexts[name] = {
|
||||
baseUrl: typeof c.baseUrl === "string" ? c.baseUrl : "http://localhost:20128",
|
||||
accessToken: typeof c.accessToken === "string" ? c.accessToken : undefined,
|
||||
apiKey: typeof c.apiKey === "string" ? c.apiKey : null,
|
||||
scope: typeof c.scope === "string" ? c.scope : undefined,
|
||||
description: typeof c.description === "string" ? c.description : undefined,
|
||||
};
|
||||
count++;
|
||||
|
||||
180
bin/cli/commands/launch-codex.mjs
Normal file
180
bin/cli/commands/launch-codex.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
/** OpenAI/Codex env keys stripped from the child so a stale OpenAI key/base-url
|
||||
* in the shell can't shadow the omniroute provider (defense-in-depth). Mirrors
|
||||
* free-claude-code's codex adapter. NOTE: this does NOT silence codex's
|
||||
* `refresh_token` log noise — that comes from a stored OpenAI session in
|
||||
* ~/.codex/auth.json, not the env; it is cosmetic and does not block requests. */
|
||||
const STRIPPED_CODEX_ENV_KEYS = [
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"OPENAI_ORG_ID",
|
||||
"OPENAI_ORGANIZATION",
|
||||
"CODEX_API_KEY",
|
||||
];
|
||||
|
||||
/** Placeholder so codex's `env_key` is always satisfied when the backend is open. */
|
||||
const NO_AUTH_SENTINEL = "omniroute-no-auth";
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
let s = String(value);
|
||||
let end = s.length;
|
||||
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
|
||||
return end === s.length ? s : s.slice(0, end);
|
||||
}
|
||||
|
||||
/** TOML assignment for a `-c key=value` codex flag (strings get quoted). */
|
||||
function tomlAssign(key, value) {
|
||||
if (typeof value === "boolean" || typeof value === "number") return `${key}=${value}`;
|
||||
return `${key}=${JSON.stringify(String(value))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OmniRoute root base URL + auth for codex, honouring (in order):
|
||||
* explicit flags → active context (remote mode) → localhost:<port>.
|
||||
* @returns {{ baseUrl:string, authToken:string|undefined }}
|
||||
*/
|
||||
export function resolveCodexTarget(opts = {}) {
|
||||
const explicit = opts.remote ?? opts.baseUrl;
|
||||
let baseUrl;
|
||||
if (explicit) {
|
||||
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
|
||||
} else {
|
||||
let fromCtx;
|
||||
try {
|
||||
fromCtx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
baseUrl = fromCtx
|
||||
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
|
||||
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let authToken = opts.apiKey ?? opts["api-key"];
|
||||
if (!authToken) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!authToken) authToken = process.env.OMNIROUTE_API_KEY;
|
||||
return { baseUrl, authToken };
|
||||
}
|
||||
|
||||
/** Health-check an OmniRoute root URL before launching Codex. */
|
||||
async function healthCheck(baseUrl, timeoutMs = 3000) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the env for the Codex child: strip stale OpenAI/Codex creds, then set
|
||||
* OMNIROUTE_API_KEY (the provider env_key) to the resolved token or a sentinel.
|
||||
* @param {Record<string,string>} baseEnv
|
||||
* @param {string|undefined} authToken
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
export function buildCodexEnv(baseEnv, authToken) {
|
||||
const env = { ...baseEnv };
|
||||
for (const key of STRIPPED_CODEX_ENV_KEYS) delete env[key];
|
||||
env.OMNIROUTE_API_KEY = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex `-c` flags that define the `omniroute` provider inline, so launch works
|
||||
* WITHOUT a pre-existing ~/.codex/config.toml. Mirrors free-claude-code.
|
||||
* @param {string} baseUrl OmniRoute root URL (no /v1)
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function buildCodexProviderArgs(baseUrl) {
|
||||
return [
|
||||
"-c",
|
||||
tomlAssign("model_provider", "omniroute"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.name", "OmniRoute"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.base_url", `${baseUrl}/v1`),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.env_key", "OMNIROUTE_API_KEY"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.wire_api", "responses"),
|
||||
"-c",
|
||||
tomlAssign("model_providers.omniroute.requires_openai_auth", false),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{port?:string, remote?:string, profile?:string, apiKey?:string}} opts
|
||||
* @param {string[]} codexArgs pass-through args for the codex binary
|
||||
* @returns {Promise<number>} exit code
|
||||
*/
|
||||
export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
const { baseUrl, authToken } = resolveCodexTarget(opts);
|
||||
|
||||
if (!(await healthCheck(baseUrl))) {
|
||||
console.error(
|
||||
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
|
||||
"{port}",
|
||||
baseUrl
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Provider injected via -c (works without config.toml); then the profile (model),
|
||||
// then the user's pass-through args.
|
||||
const providerArgs = buildCodexProviderArgs(baseUrl);
|
||||
const profileArgs = opts.profile ? ["--profile", opts.profile] : [];
|
||||
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
|
||||
const env = buildCodexEnv(process.env, authToken);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn("codex", extraArgs, { env, stdio: "inherit" });
|
||||
child.on("error", (err) => {
|
||||
if (err?.code === "ENOENT") {
|
||||
console.error(
|
||||
"The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex"
|
||||
);
|
||||
resolve(127);
|
||||
} else {
|
||||
console.error(String(err?.message || err));
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLaunchCodex(program) {
|
||||
program
|
||||
.command("launch-codex")
|
||||
.description(
|
||||
t("launchCodex.description") || "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)")
|
||||
.option("--profile <name>", "Codex profile to activate (passed as --profile <name>)")
|
||||
.option("-p, --p <name>", "Alias for --profile")
|
||||
.option("--api-key <key>", "OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[codexArgs...]", "arguments passed through to the codex binary")
|
||||
.action(async (codexArgs, opts) => {
|
||||
const merged = { ...opts, profile: opts.profile ?? opts.p };
|
||||
const exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
155
bin/cli/commands/launch.mjs
Normal file
155
bin/cli/commands/launch.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
let s = String(value);
|
||||
let end = s.length;
|
||||
while (end > 0 && s.charCodeAt(end - 1) === 47) end--;
|
||||
return end === s.length ? s : s.slice(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clean child env for Claude Code pointed at OmniRoute.
|
||||
*
|
||||
* Strips inherited ANTHROPIC_* (avoids a stale shell token leaking through), then
|
||||
* injects the base URL, gateway model discovery, and auto-compact window.
|
||||
*
|
||||
* @param {Record<string,string>} baseEnv
|
||||
* @param {number|string} baseUrlOrPort a port (→ http://localhost:<port>) or a full base URL
|
||||
* @param {string|undefined} authToken
|
||||
* @param {{ configDir?:string, model?:string }} [opts]
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
export function buildClaudeEnv(baseEnv, baseUrlOrPort, authToken, opts = {}) {
|
||||
const env = { ...baseEnv };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("ANTHROPIC_")) delete env[key];
|
||||
}
|
||||
|
||||
// Accept a bare port (number/numeric string → localhost) or a full base URL.
|
||||
// Claude Code wants the ROOT URL (it appends /v1/messages itself) — no /v1 here.
|
||||
let baseUrl;
|
||||
if (typeof baseUrlOrPort === "number" || /^\d+$/.test(String(baseUrlOrPort))) {
|
||||
baseUrl = `http://localhost:${Number(baseUrlOrPort) || 20128}`;
|
||||
} else {
|
||||
baseUrl = stripTrailingSlash(String(baseUrlOrPort)).replace(/\/v1$/, "");
|
||||
}
|
||||
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
// Always set a token: when none is resolved, a sentinel keeps newer Claude Code
|
||||
// from stopping at its local login gate before it ever contacts OmniRoute (an
|
||||
// open backend ignores the value). Mirrors free-claude-code. ANTHROPIC_API_KEY
|
||||
// stays stripped (above) so it can't shadow the Bearer token.
|
||||
env.ANTHROPIC_AUTH_TOKEN = (authToken && String(authToken).trim()) || "omniroute-no-auth";
|
||||
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
|
||||
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000";
|
||||
// Profile isolation (Claude Code has no native profiles — CLAUDE_CONFIG_DIR is
|
||||
// the idiomatic mechanism: separate settings/credentials/history/cache per dir).
|
||||
if (opts.configDir) env.CLAUDE_CONFIG_DIR = opts.configDir;
|
||||
if (opts.model) env.ANTHROPIC_MODEL = opts.model;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OmniRoute base URL + auth for launch, honouring (in order):
|
||||
* explicit flags → the active context (remote mode) → localhost:<port>.
|
||||
* @param {{port?:string, remote?:string, baseUrl?:string, token?:string, apiKey?:string, context?:string}} opts
|
||||
* @returns {{ baseUrl:string, authToken:string|undefined }}
|
||||
*/
|
||||
export function resolveLaunchTarget(opts = {}) {
|
||||
const explicit = opts.remote ?? opts.baseUrl;
|
||||
let baseUrl;
|
||||
if (explicit) {
|
||||
baseUrl = stripTrailingSlash(explicit).replace(/\/v1$/, "");
|
||||
} else {
|
||||
let fromCtx;
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
fromCtx = ctx?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
baseUrl = fromCtx
|
||||
? stripTrailingSlash(fromCtx).replace(/\/v1$/, "")
|
||||
: `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let authToken = opts.token ?? opts.apiKey ?? opts["api-key"];
|
||||
if (!authToken) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
authToken = ctx?.accessToken || ctx?.apiKey || undefined;
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!authToken) authToken = process.env.ANTHROPIC_AUTH_TOKEN ?? process.env.OMNIROUTE_API_KEY;
|
||||
return { baseUrl, authToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts
|
||||
* @param {string[]} claudeArgs pass-through args for the claude binary
|
||||
* @returns {Promise<number>} exit code
|
||||
*/
|
||||
export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
||||
const { baseUrl, authToken } = resolveLaunchTarget(opts);
|
||||
|
||||
// Health check the (possibly remote) proxy before launching.
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
} catch {
|
||||
console.error(
|
||||
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
|
||||
"{port}",
|
||||
baseUrl
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const configDir = opts.profile
|
||||
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
|
||||
: undefined;
|
||||
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
|
||||
child.on("error", (err) => {
|
||||
if (err && err.code === "ENOENT") {
|
||||
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
|
||||
resolve(127);
|
||||
} else {
|
||||
console.error(String(err?.message || err));
|
||||
resolve(1);
|
||||
}
|
||||
});
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLaunch(program) {
|
||||
program
|
||||
.command("launch")
|
||||
.description(
|
||||
t("launch.description") || "Launch Claude Code pointed at OmniRoute (local or remote)"
|
||||
)
|
||||
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
|
||||
.option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
|
||||
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
|
||||
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[claudeArgs...]", "arguments passed through to the claude binary")
|
||||
.action(async (claudeArgs, opts) => {
|
||||
const exitCode = await runLaunchCommand(opts, claudeArgs ?? []);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { writeFileSync, appendFileSync, existsSync, unlinkSync } from "node:fs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { getBaseUrl, buildHeaders } from "../api.mjs";
|
||||
|
||||
export function registerLogs(program) {
|
||||
program
|
||||
@@ -9,7 +10,7 @@ export function registerLogs(program) {
|
||||
.option("--filter <level>", t("logs.filter"))
|
||||
.option("--lines <n>", t("logs.lines"), "100")
|
||||
.option("--timeout <ms>", t("logs.timeout"), "30000")
|
||||
.option("--base-url <url>", t("logs.baseUrl"), "http://localhost:20128")
|
||||
.option("--base-url <url>", t("logs.baseUrl"))
|
||||
.option("--request-id <id>", t("logs.requestId"))
|
||||
.option("--api-key <key>", t("logs.apiKey"))
|
||||
.option("--combo <name>", t("logs.combo"))
|
||||
@@ -19,7 +20,14 @@ export function registerLogs(program) {
|
||||
.option("--export <path>", t("logs.export"))
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const exitCode = await runLogsCommand({ ...opts, output: globalOpts.output });
|
||||
// `--context` and `--output` are global options, so forward them explicitly:
|
||||
// runLogsCommand resolves the base URL via getBaseUrl({ context }), and without
|
||||
// this a user's `--context` would be silently dropped.
|
||||
const exitCode = await runLogsCommand({
|
||||
...opts,
|
||||
context: globalOpts.context,
|
||||
output: globalOpts.output,
|
||||
});
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
@@ -67,7 +75,10 @@ function buildLogFilter(opts) {
|
||||
}
|
||||
|
||||
export async function runLogsCommand(opts = {}) {
|
||||
const baseUrl = opts.baseUrl || opts["base-url"] || "http://localhost:20128";
|
||||
// Resolve the base URL the same way every other CLI command does: an explicit
|
||||
// --base-url wins, otherwise fall back to the active context / env / localhost.
|
||||
// Without this, `logs` always hit localhost and ignored a connected remote.
|
||||
const baseUrl = opts.baseUrl || opts["base-url"] || getBaseUrl({ context: opts.context });
|
||||
const follow = opts.follow ?? false;
|
||||
const timeout = parseInt(String(opts.timeout || "30000"), 10);
|
||||
const isJson = opts.output === "json";
|
||||
@@ -82,8 +93,21 @@ export async function runLogsCommand(opts = {}) {
|
||||
// Pass only level filters to the stream (server-side); other filters are client-side
|
||||
const levelFilters = opts.filter ? opts.filter.split(",").map((f) => f.trim()) : [];
|
||||
|
||||
// Authenticate the log stream. The /api/cli-tools/logs endpoint requires the
|
||||
// management token; build the same headers (scoped context token + CLI token)
|
||||
// that apiFetch uses, so `logs` works against authenticated/remote servers.
|
||||
// NOTE: --api-key here is a client-side log *filter* (see buildLogFilter), not
|
||||
// an auth credential, so it is deliberately not forwarded to buildHeaders.
|
||||
const headers = await buildHeaders({ baseUrl, context: opts.context });
|
||||
|
||||
const { createLogStream } = await import("../../../src/lib/cli-helper/log-streamer.js");
|
||||
const { stream, stop } = createLogStream({ baseUrl, filters: levelFilters, follow, timeout });
|
||||
const { stream, stop } = createLogStream({
|
||||
baseUrl,
|
||||
filters: levelFilters,
|
||||
follow,
|
||||
timeout,
|
||||
headers,
|
||||
});
|
||||
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
294
bin/cli/commands/redis.mjs
Normal file
294
bin/cli/commands/redis.mjs
Normal file
@@ -0,0 +1,294 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { execFile as execFileCb } from "node:child_process";
|
||||
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
const DEFAULT_IMAGE = "docker.io/redis:7-alpine";
|
||||
const DEFAULT_NAME = "omniroute-redis";
|
||||
const DEFAULT_PORT = "6379";
|
||||
const DEFAULT_VOLUME = "omniroute-redis-data";
|
||||
|
||||
const RUNTIME_PREFERENCE = ["podman", "docker"];
|
||||
|
||||
async function detectRuntime() {
|
||||
for (const candidate of RUNTIME_PREFERENCE) {
|
||||
try {
|
||||
await execFile(candidate, ["--version"], { timeout: 3000 });
|
||||
return candidate;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function containerExists(runtime, name) {
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
|
||||
return stdout.trim() === name;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function containerRunning(runtime, name) {
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, ["ps", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
|
||||
return stdout.trim() === name;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pingRedis(port) {
|
||||
// Minimal TCP probe via /dev/tcp — works in bash/zsh but Node has no
|
||||
// native equivalent, so spawn a short-lived `redis-cli` if available,
|
||||
// otherwise fall back to a raw socket connect.
|
||||
return new Promise((resolve) => {
|
||||
import("node:net").then(({ createConnection }) => {
|
||||
const socket = createConnection({ port: Number(port), host: "127.0.0.1" });
|
||||
const timeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve(false);
|
||||
}, 1500);
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(timeout);
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.once("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function colorize(text, code) {
|
||||
if (process.stdout.isTTY === false) return text;
|
||||
return `\x1b[${code}m${text}\x1b[0m`;
|
||||
}
|
||||
|
||||
function info(msg) {
|
||||
console.log(colorize("•", "36") + " " + msg);
|
||||
}
|
||||
|
||||
function success(msg) {
|
||||
console.log(colorize("✓", "32") + " " + msg);
|
||||
}
|
||||
|
||||
function warn(msg) {
|
||||
console.error(colorize("!", "33") + " " + msg);
|
||||
}
|
||||
|
||||
function fail(msg) {
|
||||
console.error(colorize("✗", "31") + " " + msg);
|
||||
}
|
||||
|
||||
export function registerRedis(program) {
|
||||
const redis = program
|
||||
.command("redis")
|
||||
.description(
|
||||
t("redis.description") ||
|
||||
"Launch a 1-click local Redis container (Podman or Docker) for OmniRoute caching and quota tracking"
|
||||
);
|
||||
|
||||
redis
|
||||
.command("up")
|
||||
.description("Start the local Redis container")
|
||||
.option("-p, --port <port>", "Host port to expose", DEFAULT_PORT)
|
||||
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
|
||||
.option("-i, --image <image>", "Container image", DEFAULT_IMAGE)
|
||||
.option("--no-pull", "Skip pulling the image if it is missing")
|
||||
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
|
||||
.option("--password <password>", "Set a Redis password (AUTH)")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runRedisUpCommand({ ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
redis
|
||||
.command("down")
|
||||
.description("Stop and remove the local Redis container")
|
||||
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
|
||||
.option("--keep-data", "Keep the named volume for next start")
|
||||
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runRedisDownCommand({ ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
redis
|
||||
.command("status")
|
||||
.description("Show status of the local Redis container")
|
||||
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
|
||||
.option("-p, --port <port>", "Host port", DEFAULT_PORT)
|
||||
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
const exitCode = await runRedisStatusCommand({ ...opts, output: globalOpts.output });
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
|
||||
async function pickRuntime(forced) {
|
||||
if (forced) {
|
||||
try {
|
||||
await execFile(forced, ["--version"], { timeout: 3000 });
|
||||
return forced;
|
||||
} catch (err) {
|
||||
fail(`Forced runtime '${forced}' not available: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const detected = await detectRuntime();
|
||||
if (!detected) {
|
||||
fail("Neither podman nor docker found on PATH. Install one or pass --runtime.");
|
||||
return null;
|
||||
}
|
||||
return detected;
|
||||
}
|
||||
|
||||
export async function runRedisUpCommand(opts = {}) {
|
||||
const runtime = await pickRuntime(opts.runtime);
|
||||
if (!runtime) return 1;
|
||||
|
||||
const name = opts.name || DEFAULT_NAME;
|
||||
const port = opts.port || DEFAULT_PORT;
|
||||
const image = opts.image || DEFAULT_IMAGE;
|
||||
|
||||
const exists = await containerExists(runtime, name);
|
||||
const running = exists && (await containerRunning(runtime, name));
|
||||
|
||||
if (running) {
|
||||
success(`Container '${name}' is already running on port ${port}.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (exists && !opts.pull) {
|
||||
info(`Starting existing container '${name}'…`);
|
||||
try {
|
||||
await execFile(runtime, ["start", name]);
|
||||
success(`Container '${name}' started on port ${port}.`);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
fail(`Failed to start existing container: ${err.message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.pull) {
|
||||
info(`Checking if image '${image}' is present locally…`);
|
||||
let present = false;
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, ["images", "--format", "{{.Repository}}:{{.Tag}}"]);
|
||||
present = stdout.split("\n").some((line) => line.trim() === image);
|
||||
} catch {
|
||||
// ignore — fall through to pull
|
||||
}
|
||||
if (!present) {
|
||||
info(`Image not found locally — pulling '${image}'…`);
|
||||
try {
|
||||
await execFile(runtime, ["pull", image]);
|
||||
} catch (err) {
|
||||
fail(`Failed to pull image: ${err.message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const args = [
|
||||
"run",
|
||||
"-d",
|
||||
"--name", name,
|
||||
"--restart", "unless-stopped",
|
||||
"-p", `${port}:6379`,
|
||||
"-v", `${DEFAULT_VOLUME}:/data`,
|
||||
];
|
||||
if (opts.password) {
|
||||
args.push("-e", `REDIS_PASSWORD=${opts.password}`);
|
||||
}
|
||||
args.push(image, "redis-server", "--appendonly", "yes");
|
||||
if (opts.password) args.push("--requirepass", opts.password);
|
||||
|
||||
info(`Launching ${runtime} run ${args.join(" ")}`);
|
||||
try {
|
||||
await execFile(runtime, args);
|
||||
success(`Container '${name}' is now running on redis://127.0.0.1:${port}`);
|
||||
info(`Set OMNIROUTE_REDIS_URL=redis://127.0.0.1:${port} in your .env to wire OmniRoute to it.`);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
fail(`Failed to launch container: ${err.message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRedisDownCommand(opts = {}) {
|
||||
const runtime = await pickRuntime(opts.runtime);
|
||||
if (!runtime) return 1;
|
||||
|
||||
const name = opts.name || DEFAULT_NAME;
|
||||
|
||||
if (!(await containerExists(runtime, name))) {
|
||||
info(`Container '${name}' does not exist — nothing to do.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await execFile(runtime, ["rm", "-f", name]);
|
||||
success(`Removed container '${name}'.`);
|
||||
} catch (err) {
|
||||
fail(`Failed to remove container: ${err.message}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!opts.keepData) {
|
||||
try {
|
||||
await execFile(runtime, ["volume", "rm", DEFAULT_VOLUME]);
|
||||
success(`Removed volume '${DEFAULT_VOLUME}'.`);
|
||||
} catch (err) {
|
||||
warn(`Could not remove volume '${DEFAULT_VOLUME}': ${err.message}`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runRedisStatusCommand(opts = {}) {
|
||||
const runtime = await pickRuntime(opts.runtime);
|
||||
if (!runtime) return 1;
|
||||
|
||||
const name = opts.name || DEFAULT_NAME;
|
||||
const port = opts.port || DEFAULT_PORT;
|
||||
|
||||
const exists = await containerExists(runtime, name);
|
||||
if (!exists) {
|
||||
console.log(JSON.stringify({ runtime, name, port, exists: false, running: false, reachable: false }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const running = await containerRunning(runtime, name);
|
||||
const reachable = running ? await pingRedis(port) : false;
|
||||
|
||||
if (opts.json || opts.output === "json") {
|
||||
console.log(JSON.stringify({ runtime, name, port, exists, running, reachable }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`\n\x1b[1m\x1b[36mRedis (${runtime})\x1b[0m\n`);
|
||||
console.log(` Container: ${name}`);
|
||||
console.log(` Exists: ${exists ? "yes" : "no"}`);
|
||||
console.log(` Running: ${running ? "yes" : "no"}`);
|
||||
console.log(` Reachable: ${reachable ? "yes" : "no"} (port ${port})`);
|
||||
if (running && !reachable) {
|
||||
warn("Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?");
|
||||
}
|
||||
if (!running) {
|
||||
info(`Run 'omniroute redis up' to launch it.`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { registerBackup, registerRestore } from "./backup.mjs";
|
||||
import { registerHealth } from "./health.mjs";
|
||||
import { registerQuota } from "./quota.mjs";
|
||||
import { registerCache } from "./cache.mjs";
|
||||
import { registerRedis } from "./redis.mjs";
|
||||
import { registerMcp } from "./mcp.mjs";
|
||||
import { registerA2a } from "./a2a.mjs";
|
||||
import { registerTunnel } from "./tunnel.mjs";
|
||||
@@ -55,6 +56,25 @@ import { registerRuntime } from "./runtime.mjs";
|
||||
import { registerTray } from "./tray.mjs";
|
||||
import { registerAutostart } from "./autostart.mjs";
|
||||
import { registerRepl } from "./repl.mjs";
|
||||
import { registerLaunch } from "./launch.mjs";
|
||||
import { registerLaunchCodex } from "./launch-codex.mjs";
|
||||
import { registerSetupCodex } from "./setup-codex.mjs";
|
||||
import { registerSetupClaude } from "./setup-claude.mjs";
|
||||
import { registerSetupOpencode } from "./setup-opencode.mjs";
|
||||
import { registerSetupCline } from "./setup-cline.mjs";
|
||||
import { registerSetupKilo } from "./setup-kilo.mjs";
|
||||
import { registerSetupContinue } from "./setup-continue.mjs";
|
||||
import { registerSetupCursor } from "./setup-cursor.mjs";
|
||||
import { registerSetupRoo } from "./setup-roo.mjs";
|
||||
import { registerSetupCrush } from "./setup-crush.mjs";
|
||||
import { registerSetupGoose } from "./setup-goose.mjs";
|
||||
import { registerSetupQwen } from "./setup-qwen.mjs";
|
||||
import { registerSetupAider } from "./setup-aider.mjs";
|
||||
import { registerSetupGemini } from "./setup-gemini.mjs";
|
||||
import { registerConnect } from "./connect.mjs";
|
||||
import { registerContexts } from "./contexts.mjs";
|
||||
import { registerTokens } from "./tokens.mjs";
|
||||
import { registerConfigure } from "./configure.mjs";
|
||||
import { registerApiCommands } from "../api-commands/registry.mjs";
|
||||
import { registerPlugin } from "./plugin.mjs";
|
||||
|
||||
@@ -107,6 +127,7 @@ export function registerCommands(program) {
|
||||
registerHealth(program);
|
||||
registerQuota(program);
|
||||
registerCache(program);
|
||||
registerRedis(program);
|
||||
registerMcp(program);
|
||||
registerA2a(program);
|
||||
registerTunnel(program);
|
||||
@@ -117,6 +138,25 @@ export function registerCommands(program) {
|
||||
registerTray(program);
|
||||
registerAutostart(program);
|
||||
registerRepl(program);
|
||||
registerLaunch(program);
|
||||
registerLaunchCodex(program);
|
||||
registerSetupCodex(program);
|
||||
registerSetupClaude(program);
|
||||
registerSetupOpencode(program);
|
||||
registerSetupCline(program);
|
||||
registerSetupKilo(program);
|
||||
registerSetupContinue(program);
|
||||
registerSetupCursor(program);
|
||||
registerSetupRoo(program);
|
||||
registerSetupCrush(program);
|
||||
registerSetupGoose(program);
|
||||
registerSetupQwen(program);
|
||||
registerSetupAider(program);
|
||||
registerSetupGemini(program);
|
||||
registerConnect(program);
|
||||
registerContexts(program);
|
||||
registerTokens(program);
|
||||
registerConfigure(program);
|
||||
registerApiCommands(program);
|
||||
registerPlugin(program);
|
||||
}
|
||||
|
||||
146
bin/cli/commands/setup-aider.mjs
Normal file
146
bin/cli/commands/setup-aider.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* omniroute setup-aider — configure Aider (aider.chat) for OmniRoute.
|
||||
*
|
||||
* Aider (LiteLLM under the hood) talks to an OpenAI-compatible endpoint via env
|
||||
* `OPENAI_API_BASE` (ROOT url — LiteLLM appends /v1/chat/completions) + the model
|
||||
* flag `--model openai/<model>`. This writes ~/.aider.conf.yml (openai-api-base +
|
||||
* model) — the key stays in OPENAI_API_KEY (env, never the file) — and prints the
|
||||
* guaranteed env recipe + headless command. Remote-aware.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripToRoot(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s.slice(0, -3) : s;
|
||||
}
|
||||
|
||||
/** Resolve OPENAI_API_BASE (ROOT, no /v1 — LiteLLM appends) + apiKey. */
|
||||
export function resolveAiderTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { apiBase: root, apiKey };
|
||||
}
|
||||
|
||||
/** Merge openai-api-base + model into an .aider.conf.yml object (preserve rest). */
|
||||
export function buildAiderConfig(existing, { apiBase, model }) {
|
||||
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
cfg["openai-api-base"] = apiBase;
|
||||
if (model) cfg.model = `openai/${model}`;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** The guaranteed env + run recipe (pure → testable). */
|
||||
export function buildAiderRecipe({ apiBase, model }) {
|
||||
return [
|
||||
`export OPENAI_API_BASE=${apiBase}`,
|
||||
"export OPENAI_API_KEY=$OMNIROUTE_API_KEY",
|
||||
`aider --model openai/${model}`,
|
||||
`# headless: aider --model openai/${model} --message "reply OK" --yes`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function readYamlSafe(yaml, path) {
|
||||
try {
|
||||
if (existsSync(path)) return yaml.load(readFileSync(path, "utf8")) || {};
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(apiBase, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupAiderCommand(opts = {}) {
|
||||
const { apiBase, apiKey } = resolveAiderTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml");
|
||||
|
||||
printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)");
|
||||
printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(apiBase, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Aider (without the openai/ prefix)");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id> (the openai/ prefix is added automatically).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const yaml = await import("js-yaml");
|
||||
const merged = buildAiderConfig(readYamlSafe(yaml, configPath), { apiBase, model });
|
||||
const out = yaml.dump(merged, { lineWidth: -1 });
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + out);
|
||||
printInfo(`[dry-run] → ${configPath}`);
|
||||
} else {
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath}`);
|
||||
}
|
||||
printInfo("\nProvide the key + run (the key stays in the env, never the file):");
|
||||
console.log(buildAiderRecipe({ apiBase, model }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupAider(program) {
|
||||
program
|
||||
.command("setup-aider")
|
||||
.description("Configure Aider for OmniRoute: write ~/.aider.conf.yml + print the env recipe")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id (the openai/ prefix is added automatically)")
|
||||
.option("--config-path <path>", ".aider.conf.yml path (default: ~/.aider.conf.yml)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupAiderCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
152
bin/cli/commands/setup-claude.mjs
Normal file
152
bin/cli/commands/setup-claude.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* omniroute setup-claude — Remote-aware Claude Code profile generator.
|
||||
*
|
||||
* Claude Code has no native profile files (unlike Codex). The idiomatic way to
|
||||
* keep multiple named configs is `CLAUDE_CONFIG_DIR` — a separate config dir per
|
||||
* profile (its own settings.json, credentials, history, cache). This command
|
||||
* fetches the live /v1/models catalog from a (possibly remote) OmniRoute and
|
||||
* writes `~/.claude/profiles/<name>/settings.json` for each supported model,
|
||||
* reusing the SAME profile names as `setup-codex` (glm52, kimi-k27, …).
|
||||
*
|
||||
* Launch a profile with: omniroute launch --profile <name>
|
||||
* (which injects ANTHROPIC_AUTH_TOKEN from the active context — the token is
|
||||
* never written to disk). Or export ANTHROPIC_AUTH_TOKEN and run:
|
||||
* CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude
|
||||
*
|
||||
* Idempotent: re-running overwrites each profile's settings.json in place.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { categoriseModel } from "./setup-codex.mjs";
|
||||
|
||||
/** Map a Codex-style effort to a Claude Code settings.json effortLevel. */
|
||||
function effortLevelFor(cfg) {
|
||||
// Codex categories use xhigh/high/low/undefined; Claude Code accepts the same
|
||||
// names (low|medium|high|xhigh). Pass through, omit for the "simple" tier.
|
||||
return cfg.effort || undefined;
|
||||
}
|
||||
|
||||
/** Build the settings.json content for one Claude Code profile. */
|
||||
export function buildProfileSettings(modelId, baseUrl, cfg) {
|
||||
const env = {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_MODEL: modelId,
|
||||
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW: "190000",
|
||||
};
|
||||
const settings = {
|
||||
$schema: "https://json.schemastore.org/claude-code-settings.json",
|
||||
model: modelId,
|
||||
env,
|
||||
};
|
||||
const effort = effortLevelFor(cfg);
|
||||
if (effort) settings.effortLevel = effort;
|
||||
// NOTE: ANTHROPIC_AUTH_TOKEN is intentionally NOT written here — `omniroute
|
||||
// launch --profile` injects it from the active context, keeping the secret off
|
||||
// disk. For direct `CLAUDE_CONFIG_DIR=… claude` use, export it in your shell.
|
||||
return JSON.stringify(settings, null, 2) + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{remote?:string, port?:string, apiKey?:string, claudeHome?:string, dryRun?:boolean, only?:string}} opts
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function runSetupClaudeCommand(opts = {}) {
|
||||
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
|
||||
const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
const apiKey = opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? "";
|
||||
const claudeHome = opts.claudeHome ?? opts["claude-home"] ?? join(os.homedir(), ".claude");
|
||||
const profilesRoot = join(claudeHome, "profiles");
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
printHeading("OmniRoute → Claude Code profile generator");
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
|
||||
// ── Fetch model catalog ───────────────────────────────────────────────────
|
||||
let models;
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
const body = await res.json();
|
||||
models = body.data ?? body.models ?? [];
|
||||
} catch (err) {
|
||||
printError(`Failed to fetch models: ${err.message}`);
|
||||
printInfo(
|
||||
"Make sure OmniRoute is running and the --remote URL is correct.\n" +
|
||||
"You may also need --api-key if OmniRoute requires authentication."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printInfo(`Received ${models.length} models from ${baseUrl}`);
|
||||
|
||||
if (!dryRun && !existsSync(profilesRoot)) {
|
||||
mkdirSync(profilesRoot, { recursive: true });
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : m.id ?? "";
|
||||
if (!id) continue;
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
|
||||
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
|
||||
const dir = join(profilesRoot, cfg.name);
|
||||
const filePath = join(dir, "settings.json");
|
||||
const content = buildProfileSettings(id, baseUrl, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${filePath} ──`);
|
||||
console.log(content);
|
||||
} else {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
printSuccess(` ✓ profiles/${cfg.name}/settings.json (${id})`);
|
||||
}
|
||||
written++;
|
||||
}
|
||||
|
||||
const skipped = models.length - written;
|
||||
if (!dryRun) {
|
||||
console.log("");
|
||||
printSuccess(`${written} Claude Code profiles written to ${profilesRoot}`);
|
||||
if (skipped > 0) printInfo(`${skipped} models skipped (no matching profile pattern)`);
|
||||
console.log("\nTo use a profile:");
|
||||
console.log(" omniroute launch --profile <name> # e.g. omniroute launch --profile glm52");
|
||||
console.log(" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude (export ANTHROPIC_AUTH_TOKEN first)");
|
||||
} else {
|
||||
console.log(`\n[dry-run] ${written} profiles would be written (${skipped} skipped)`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupClaude(program) {
|
||||
program
|
||||
.command("setup-claude")
|
||||
.description(
|
||||
"Fetch the live model catalog from OmniRoute (local or remote VPS) and generate " +
|
||||
"~/.claude/profiles/<name>/ Claude Code profiles (CLAUDE_CONFIG_DIR) for each model"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--claude-home <dir>", "Claude home dir (default: ~/.claude)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — only matching model IDs (e.g. glm,kimi)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const exitCode = await runSetupClaudeCommand(opts);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
160
bin/cli/commands/setup-cline.mjs
Normal file
160
bin/cli/commands/setup-cline.mjs
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* omniroute setup-cline — configure the Cline AI coding agent to use OmniRoute.
|
||||
*
|
||||
* Cline's VS Code extension keeps its config in VS Code's opaque globalStorage
|
||||
* (not file-writable). Its CLI/standalone mode reads ~/.cline/data/. This command
|
||||
* writes the CLI-mode files (matching the OmniRoute dashboard) AND prints the
|
||||
* Base URL / model to paste into the VS Code extension UI.
|
||||
*
|
||||
* Cline uses the OpenAI-compatible provider: openAiBaseUrl is the ROOT URL
|
||||
* (no /v1 — Cline appends /v1/chat/completions). Plan + Act modes are set to the
|
||||
* same provider/model. The key goes in secrets.json (Cline has no env ref).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripToRoot(url) {
|
||||
let s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s.slice(0, -3) : s;
|
||||
}
|
||||
|
||||
/** Resolve baseUrl (ROOT, no /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveClineTarget(opts = {}) {
|
||||
let baseUrl;
|
||||
if (opts.remote) baseUrl = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
baseUrl = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl, apiKey };
|
||||
}
|
||||
|
||||
/** Merge OmniRoute openai-compatible settings into Cline's globalState (Plan + Act). */
|
||||
export function buildClineGlobalState(existing, { baseUrl, model }) {
|
||||
const gs = { ...(existing || {}) };
|
||||
gs.actModeApiProvider = "openai";
|
||||
gs.planModeApiProvider = "openai";
|
||||
gs.openAiBaseUrl = baseUrl; // ROOT — Cline appends /v1/chat/completions
|
||||
if (model) {
|
||||
gs.openAiModelId = model;
|
||||
gs.planModeOpenAiModelId = model;
|
||||
}
|
||||
return gs;
|
||||
}
|
||||
|
||||
/** Merge the API key into Cline's secrets (Cline has no env-var reference). */
|
||||
export function buildClineSecrets(existing, { apiKey }) {
|
||||
return { ...(existing || {}), openAiApiKey: apiKey || "sk_omniroute" };
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing → start fresh */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupClineCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveClineTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const clineDir = opts.clineDir ?? opts["cline-dir"] ?? join(os.homedir(), ".cline", "data");
|
||||
|
||||
printHeading("OmniRoute → Cline (OpenAI-compatible)");
|
||||
printInfo(`Server: ${baseUrl}`);
|
||||
|
||||
// Resolve the model (Cline needs one explicit id — no auto-discovery).
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Cline");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id> (Cline has no model auto-discovery).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const gsPath = join(clineDir, "globalState.json");
|
||||
const secPath = join(clineDir, "secrets.json");
|
||||
const globalState = buildClineGlobalState(readJson(gsPath), { baseUrl, model });
|
||||
const secrets = buildClineSecrets(readJson(secPath), { apiKey });
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${gsPath} ──`);
|
||||
console.log(JSON.stringify({ actModeApiProvider: globalState.actModeApiProvider, planModeApiProvider: globalState.planModeApiProvider, openAiBaseUrl: globalState.openAiBaseUrl, openAiModelId: globalState.openAiModelId }, null, 2));
|
||||
console.log(`\n── [dry-run] ${secPath} ── (openAiApiKey: ${apiKey ? "set" : "sk_omniroute"})`);
|
||||
} else {
|
||||
if (!existsSync(clineDir)) mkdirSync(clineDir, { recursive: true });
|
||||
writeFileSync(gsPath, JSON.stringify(globalState, null, 2) + "\n", "utf8");
|
||||
writeFileSync(secPath, JSON.stringify(secrets, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Wrote ${gsPath}`);
|
||||
printSuccess(`Wrote ${secPath}`);
|
||||
}
|
||||
|
||||
// The VS Code extension uses opaque globalStorage — can't be file-written.
|
||||
printInfo("\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):");
|
||||
printInfo(` Base URL: ${baseUrl} (NOT /v1 — Cline appends it)`);
|
||||
printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
|
||||
printInfo(` Model: ${model}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupCline(program) {
|
||||
program
|
||||
.command("setup-cline")
|
||||
.description(
|
||||
"Configure Cline for OmniRoute: write ~/.cline/data (CLI mode) + print VS Code extension settings"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Cline (required unless picked interactively)")
|
||||
.option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupClineCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
226
bin/cli/commands/setup-codex.mjs
Normal file
226
bin/cli/commands/setup-codex.mjs
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* omniroute setup-codex — Remote-aware Codex CLI profile generator.
|
||||
*
|
||||
* Connects to a running OmniRoute instance (local or remote VPS), fetches the
|
||||
* live model catalog via GET /v1/models, then generates ~/.codex/<name>.config.toml
|
||||
* profile files for each model — so you can switch providers with a single flag
|
||||
* (`codex --profile glm52`) without editing config files by hand.
|
||||
*
|
||||
* Primary use-case: configure a local Codex CLI to use models from a VPS.
|
||||
* omniroute setup-codex --remote http://100.67.86.91:20128 --api-key sk-xxx
|
||||
*
|
||||
* The command is idempotent: re-running updates existing profile files in place.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
// ── Model categorisation ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map a model ID (as returned by /v1/models) to a Codex profile configuration.
|
||||
* Returns null for models that should not get their own profile (e.g. aliases).
|
||||
*
|
||||
* Exported so the Claude Code profile generator (`setup-claude`) reuses the SAME
|
||||
* profile names (glm52, kimi-k27, …) for cross-CLI consistency.
|
||||
*
|
||||
* @param {string} modelId
|
||||
* @returns {{ name:string, ctx:number, compact:number, effort?:string, summary?:boolean, toolLimit:number }|null}
|
||||
*/
|
||||
export function categoriseModel(modelId) {
|
||||
const id = modelId.toLowerCase();
|
||||
|
||||
// ── Thinking models (max effort, detailed summary) ────────────────────────
|
||||
const thinkingPatterns = [
|
||||
{ re: /kmc\/kimi-k2\.7/, name: "kimi-k27", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /kmc\/kimi-k2\.6/, name: "kimi-k26", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5\.2-max/, name: "glm52max", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5\.2$/, name: "glm52", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /opencode-go\/mimo-v2\.5-pro/, name: "mimo-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /opencode-go\/qwen3\.7-plus/, name: "qwen37plus", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
// ── Good models (high effort) ─────────────────────────────────────────────
|
||||
const goodPatterns = [
|
||||
{ re: /ollamacloud\/deepseek-v4-pro/, name: "deepseek-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /opencode-go\/mimo-v2\.5$/, name: "mimo", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
];
|
||||
|
||||
// ── Simple models (no effort) ─────────────────────────────────────────────
|
||||
const simplePatterns = [
|
||||
{ re: /ollamacloud\/gemma4:31b/, name: "gemma4", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
{ re: /ollamacloud\/nemotron-3-super/, name: "nemotron", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
{ re: /ollamacloud\/gpt-oss:20b/, name: "gptoss", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
// ── Fast models (low effort) ──────────────────────────────────────────────
|
||||
const fastPatterns = [
|
||||
{ re: /ollamacloud\/deepseek-v4-flash/, name: "deepseek-flash", ctx: 65536, compact: 56000, toolLimit: 16384 },
|
||||
{ re: /ollamacloud\/gemini-3-flash/, name: "gemini-flash", ctx: 1000000, compact: 850000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5-turbo/, name: "glm5turbo", ctx: 131072, compact: 112000, toolLimit: 16384 },
|
||||
{ re: /glm\/glm-4\.7-flash/, name: "glm47flash", ctx: 131072, compact: 112000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
for (const p of thinkingPatterns) {
|
||||
if (p.re.test(id)) return { ...p, effort: "xhigh", summary: true };
|
||||
}
|
||||
for (const p of goodPatterns) {
|
||||
if (p.re.test(id)) return { ...p, effort: "high", summary: false };
|
||||
}
|
||||
for (const p of simplePatterns) {
|
||||
if (p.re.test(id)) return { ...p, effort: undefined, summary: false };
|
||||
}
|
||||
for (const p of fastPatterns) {
|
||||
if (p.re.test(id)) return { ...p, effort: "low", summary: false };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build the TOML content for a single profile. */
|
||||
function buildProfileToml(modelId, cfg) {
|
||||
const lines = [
|
||||
`# codex --profile ${cfg.name}`,
|
||||
`# ${modelId}`,
|
||||
`model = "${modelId}"`,
|
||||
`model_provider = "omniroute"`,
|
||||
];
|
||||
|
||||
if (cfg.effort) {
|
||||
lines.push(`model_reasoning_effort = "${cfg.effort}"`);
|
||||
}
|
||||
if (cfg.summary) {
|
||||
lines.push(`model_reasoning_summary = "detailed"`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`model_context_window = ${cfg.ctx}`,
|
||||
`model_auto_compact_token_limit = ${cfg.compact}`,
|
||||
`tool_output_token_limit = ${cfg.toolLimit}`
|
||||
);
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ── Command ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {{remote?:string, port?:string, apiKey?:string, codexHome?:string, dryRun?:boolean, only?:string}} opts
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function runSetupCodexCommand(opts = {}) {
|
||||
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
|
||||
const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/v1$/, "");
|
||||
const apiKey = opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? "";
|
||||
const codexHome = opts.codexHome ?? opts["codex-home"] ?? join(os.homedir(), ".codex");
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
printHeading(`OmniRoute → Codex CLI profile generator`);
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
|
||||
// ── Fetch model catalog ───────────────────────────────────────────────────
|
||||
let models;
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
|
||||
const res = await fetch(`${baseUrl}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
const body = await res.json();
|
||||
models = body.data ?? body.models ?? [];
|
||||
} catch (err) {
|
||||
printError(`Failed to fetch models: ${err.message}`);
|
||||
printInfo(
|
||||
"Make sure OmniRoute is running and the --remote URL is correct.\n" +
|
||||
"You may also need --api-key if OmniRoute requires authentication."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printInfo(`Received ${models.length} models from ${baseUrl}`);
|
||||
|
||||
// ── Ensure codex home exists ──────────────────────────────────────────────
|
||||
if (!dryRun && !existsSync(codexHome)) {
|
||||
mkdirSync(codexHome, { recursive: true });
|
||||
}
|
||||
|
||||
// ── Generate profiles ─────────────────────────────────────────────────────
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : (m.id ?? "");
|
||||
if (!id) continue;
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
|
||||
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
|
||||
const filePath = join(codexHome, `${cfg.name}.config.toml`);
|
||||
const content = buildProfileToml(id, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${filePath} ──`);
|
||||
console.log(content);
|
||||
} else {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
printSuccess(` ✓ ${cfg.name}.config.toml (${id})`);
|
||||
}
|
||||
written++;
|
||||
}
|
||||
|
||||
skipped = models.length - written;
|
||||
|
||||
if (!dryRun) {
|
||||
console.log("");
|
||||
printSuccess(`${written} profiles written to ${codexHome}`);
|
||||
if (skipped > 0) {
|
||||
printInfo(`${skipped} models skipped (no matching profile pattern)`);
|
||||
}
|
||||
console.log("\nTo use a profile:");
|
||||
console.log(" codex --profile <name> # e.g. codex --profile glm52");
|
||||
console.log(" codex -p <name> # short form");
|
||||
} else {
|
||||
console.log(`\n[dry-run] ${written} profiles would be written (${skipped} skipped)`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupCodex(program) {
|
||||
program
|
||||
.command("setup-codex")
|
||||
.description(
|
||||
"Fetch the live model catalog from OmniRoute (local or remote VPS) and generate " +
|
||||
"~/.codex/<name>.config.toml profiles for each supported model"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option(
|
||||
"--remote <url>",
|
||||
"Remote OmniRoute URL, e.g. http://100.67.86.91:20128 — fetches models from there"
|
||||
)
|
||||
.option(
|
||||
"--api-key <key>",
|
||||
"OmniRoute API key for the remote instance (defaults to OMNIROUTE_API_KEY env var)"
|
||||
)
|
||||
.option(
|
||||
"--codex-home <dir>",
|
||||
"Directory where profile files are written (default: ~/.codex)"
|
||||
)
|
||||
.option(
|
||||
"--only <patterns>",
|
||||
"Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)"
|
||||
)
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const exitCode = await runSetupCodexCommand(opts);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
173
bin/cli/commands/setup-continue.mjs
Normal file
173
bin/cli/commands/setup-continue.mjs
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* omniroute setup-continue — configure Continue (continue.dev) for OmniRoute.
|
||||
*
|
||||
* Continue uses a file-based, mergeable ~/.continue/config.yaml shared by the VS
|
||||
* Code / JetBrains extensions AND the `cn` CLI. Models use `provider: openai`
|
||||
* with a custom `apiBase` (WITH /v1 — Continue appends /chat/completions) and an
|
||||
* `apiKey: ${{ secrets.OMNIROUTE_API_KEY }}` reference (secret never written to
|
||||
* config.yaml). Remote-aware; curated model set with Continue roles.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { categoriseModel } from "./setup-codex.mjs";
|
||||
|
||||
const SECRET_REF = "${{ secrets.OMNIROUTE_API_KEY }}";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve apiBase (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveContinueTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { apiBase: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** Build Continue model entries (provider: openai) for the given catalog ids. */
|
||||
export function buildContinueModels(modelIds, apiBase) {
|
||||
const out = [];
|
||||
for (const id of modelIds) {
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
const roles = ["chat", "edit", "apply"];
|
||||
if (cfg.effort === "low") roles.push("autocomplete"); // fast tier → autocomplete
|
||||
out.push({
|
||||
name: `OmniRoute: ${id}`,
|
||||
provider: "openai",
|
||||
model: id,
|
||||
apiBase,
|
||||
apiKey: SECRET_REF,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge OmniRoute models into an existing Continue config object: drop any prior
|
||||
* models pointing at this apiBase, keep everything else, append the new set.
|
||||
*/
|
||||
export function mergeContinueConfig(existing, newModels, apiBase) {
|
||||
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
const prior = Array.isArray(cfg.models) ? cfg.models : [];
|
||||
const kept = prior.filter((m) => !m || m.apiBase !== apiBase);
|
||||
cfg.models = [...kept, ...newModels];
|
||||
if (!cfg.name) cfg.name = "OmniRoute Config";
|
||||
if (!cfg.version) cfg.version = "1.0";
|
||||
if (!cfg.schema) cfg.schema = "v1";
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function fetchModelIds(apiBase, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${apiBase.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch (e) {
|
||||
throw new Error(`Could not fetch models: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupContinueCommand(opts = {}) {
|
||||
const { apiBase, apiKey } = resolveContinueTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml");
|
||||
|
||||
printHeading("OmniRoute → Continue (config.yaml)");
|
||||
printInfo(`apiBase: ${apiBase}`);
|
||||
|
||||
let ids;
|
||||
try {
|
||||
ids = await fetchModelIds(apiBase, apiKey);
|
||||
} catch (e) {
|
||||
printError(e.message);
|
||||
printInfo("Make sure OmniRoute is running and --remote/--api-key are correct.");
|
||||
return 1;
|
||||
}
|
||||
if (only) ids = ids.filter((id) => only.some((f) => id.includes(f)));
|
||||
|
||||
const models = buildContinueModels(ids, apiBase);
|
||||
if (!models.length) {
|
||||
printError("No matching curated models in the catalog (try --only or check the server).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const yaml = await import("js-yaml");
|
||||
let existing = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
existing = yaml.load(readFileSync(configPath, "utf8")) || {};
|
||||
} catch {
|
||||
printInfo("Existing config.yaml unparseable — starting fresh (a .bak is kept).");
|
||||
if (!dryRun) writeFileSync(`${configPath}.bak`, readFileSync(configPath));
|
||||
existing = {};
|
||||
}
|
||||
}
|
||||
const merged = mergeContinueConfig(existing, models, apiBase);
|
||||
const out = yaml.dump(merged, { lineWidth: -1 });
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out));
|
||||
printInfo(`[dry-run] ${models.length} OmniRoute model(s) → ${configPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath} (${models.length} OmniRoute models)`);
|
||||
printInfo("\nProvide the key (config.yaml references it, not stores it):");
|
||||
printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)");
|
||||
printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env");
|
||||
printInfo("Run: cn -p \"reply OK\"");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupContinue(program) {
|
||||
program
|
||||
.command("setup-continue")
|
||||
.description(
|
||||
"Generate ~/.continue/config.yaml (Continue / cn CLI) from the OmniRoute model catalog"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
|
||||
.option("--config-path <path>", "config.yaml path (default: ~/.continue/config.yaml)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupContinueCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
148
bin/cli/commands/setup-crush.mjs
Normal file
148
bin/cli/commands/setup-crush.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* omniroute setup-crush — configure Crush (charmbracelet/crush) for OmniRoute.
|
||||
*
|
||||
* Crush is a terminal AI agent with a file-based config: ~/.config/crush/crush.json
|
||||
* (or ./crush.json). It supports a custom `openai-compat` provider. base_url must
|
||||
* include /v1; the api_key may reference an env var (`$OMNIROUTE_API_KEY`) so the
|
||||
* secret stays out of the file. Remote-aware; curated catalog models.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { categoriseModel } from "./setup-codex.mjs";
|
||||
|
||||
const API_KEY_REF = "$OMNIROUTE_API_KEY";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve base_url (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveCrushTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** Build the Crush `openai-compat` provider block from curated catalog ids. */
|
||||
export function buildCrushProvider(modelIds, baseUrl) {
|
||||
const models = [];
|
||||
for (const id of modelIds) {
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
models.push({ id, name: `OmniRoute: ${id}`, context_window: cfg.ctx });
|
||||
}
|
||||
return {
|
||||
type: "openai-compat",
|
||||
base_url: baseUrl,
|
||||
api_key: API_KEY_REF,
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge the OmniRoute provider into an existing crush.json (preserve the rest). */
|
||||
export function mergeCrushConfig(existing, provider) {
|
||||
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
cfg.providers = { ...(cfg.providers || {}), omniroute: provider };
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(baseUrl, apiKey) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
}
|
||||
|
||||
export async function runSetupCrushCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveCrushTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json");
|
||||
|
||||
printHeading("OmniRoute → Crush (openai-compat)");
|
||||
printInfo(`base_url: ${baseUrl}`);
|
||||
|
||||
let ids;
|
||||
try {
|
||||
ids = await fetchModelIds(baseUrl, apiKey);
|
||||
} catch (e) {
|
||||
printError(`Could not fetch models: ${e.message}`);
|
||||
printInfo("Make sure OmniRoute is running and --remote/--api-key are correct.");
|
||||
return 1;
|
||||
}
|
||||
if (only) ids = ids.filter((id) => only.some((f) => id.includes(f)));
|
||||
|
||||
const provider = buildCrushProvider(ids, baseUrl);
|
||||
if (!provider.models.length) {
|
||||
printError("No matching curated models (try --only or check the server).");
|
||||
return 1;
|
||||
}
|
||||
const merged = mergeCrushConfig(readJson(configPath), provider);
|
||||
const out = JSON.stringify(merged, null, 2) + "\n";
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out));
|
||||
printInfo(`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`);
|
||||
return 0;
|
||||
}
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath} (${provider.models.length} models under providers.omniroute)`);
|
||||
printInfo("Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=...");
|
||||
printInfo("Then run: crush");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupCrush(program) {
|
||||
program
|
||||
.command("setup-crush")
|
||||
.description("Generate the OmniRoute openai-compat provider in ~/.config/crush/crush.json")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
|
||||
.option("--config-path <path>", "crush.json path (default: ~/.config/crush/crush.json)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupCrushCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
108
bin/cli/commands/setup-cursor.mjs
Normal file
108
bin/cli/commands/setup-cursor.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* omniroute setup-cursor — guide Cursor to use OmniRoute.
|
||||
*
|
||||
* Cursor stores its OpenAI key + "Override OpenAI Base URL" in an opaque SQLite
|
||||
* DB (state.vscdb) with no documented stable schema — NOT safe to file-write.
|
||||
* So this command prints the exact in-app steps (and can list available models
|
||||
* from /v1/models). Note: Cursor's custom base URL only powers the Chat panel;
|
||||
* Composer / inline-edit / autocomplete stay on Cursor's own backend.
|
||||
*/
|
||||
|
||||
import { printHeading, printInfo, printSuccess } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve apiBase (WITH /v1 — Cursor appends /chat/completions) + apiKey. */
|
||||
export function resolveCursorTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { apiBase: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** The step-by-step Cursor UI instructions (pure → testable). */
|
||||
export function buildCursorInstructions({ apiBase, models }) {
|
||||
const lines = [
|
||||
"Cursor stores this config in an opaque database, so configure it in the app:",
|
||||
"",
|
||||
" 1. Cursor → Settings (Cmd/Ctrl + ,) → Models",
|
||||
" 2. Enable “Override OpenAI Base URL” and set it to:",
|
||||
` ${apiBase} (the /v1 suffix is required)`,
|
||||
" 3. Set the OpenAI API Key to your OmniRoute key (OMNIROUTE_API_KEY)",
|
||||
" 4. Add the model name(s) you want under “Models” (Cursor has no auto-discovery):",
|
||||
];
|
||||
const sample = (models && models.length ? models : ["glm/glm-5.2", "kmc/kimi-k2.7"]).slice(0, 8);
|
||||
lines.push(` e.g. ${sample.join(", ")}`);
|
||||
lines.push(" 5. Use the Chat panel (Cmd/Ctrl + L) to verify.");
|
||||
lines.push("");
|
||||
lines.push("⚠ The custom base URL powers the CHAT panel only — Composer, inline edit");
|
||||
lines.push(" (Cmd/Ctrl+K) and autocomplete keep using Cursor's own backend.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function fetchModelIds(apiBase, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${apiBase.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupCursorCommand(opts = {}) {
|
||||
const { apiBase, apiKey } = resolveCursorTarget(opts);
|
||||
printHeading("OmniRoute → Cursor");
|
||||
printInfo(`Server: ${apiBase}`);
|
||||
|
||||
let models = [];
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
const ids = await fetchModelIds(apiBase, apiKey);
|
||||
models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids;
|
||||
|
||||
console.log("\n" + buildCursorInstructions({ apiBase, models }));
|
||||
printSuccess("\nCursor is configured manually (no file written — Cursor's storage is opaque).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupCursor(program) {
|
||||
program
|
||||
.command("setup-cursor")
|
||||
.description("Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — suggest only matching model IDs")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupCursorCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
148
bin/cli/commands/setup-gemini.mjs
Normal file
148
bin/cli/commands/setup-gemini.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* omniroute setup-gemini — point the Gemini CLI at OmniRoute's Gemini endpoint.
|
||||
*
|
||||
* The Gemini CLI is NOT OpenAI-compatible — it speaks the native Gemini API.
|
||||
* OmniRoute exposes a Gemini-native surface at /v1beta (e.g.
|
||||
* /v1beta/models/<model>:generateContent), so the CLI can target it via the
|
||||
* @google/genai SDK env `GOOGLE_GEMINI_BASE_URL` (ROOT — the SDK appends /v1beta)
|
||||
* + `GEMINI_API_KEY`. There is no settings.json key for the base URL, so this is
|
||||
* primarily an env recipe; we optionally write ~/.gemini/settings.json `model`.
|
||||
*
|
||||
* ⚠ Known Gemini CLI caveat: it may ignore GOOGLE_GEMINI_BASE_URL if a cached
|
||||
* Google login exists — run `gemini` logged-out / API-key-only for it to take.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripToRoot(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1beta") ? s.slice(0, -7) : s.endsWith("/v1") ? s.slice(0, -3) : s;
|
||||
}
|
||||
|
||||
/** Resolve GOOGLE_GEMINI_BASE_URL (ROOT — SDK appends /v1beta) + apiKey. */
|
||||
export function resolveGeminiTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: root, apiKey };
|
||||
}
|
||||
|
||||
/** The guaranteed env recipe (pure → testable). */
|
||||
export function buildGeminiRecipe({ baseUrl, model }) {
|
||||
return [
|
||||
`export GOOGLE_GEMINI_BASE_URL=${baseUrl}`,
|
||||
"export GEMINI_API_KEY=$OMNIROUTE_API_KEY",
|
||||
`export GEMINI_MODEL=${model}`,
|
||||
`gemini -p "reply OK" # or: gemini (interactive)`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Merge the model into ~/.gemini/settings.json (base URL is env-only). */
|
||||
export function buildGeminiSettings(existing, { model }) {
|
||||
const s = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
if (model) s.model = model;
|
||||
return s;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchGeminiModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/v1beta/models`, {
|
||||
headers: { "x-goog-api-key": apiKey || "" },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
return (body.models || []).map((m) => String(m.name || "").replace(/^models\//, "")).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupGeminiCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveGeminiTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".gemini", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Gemini CLI (native Gemini /v1beta endpoint)");
|
||||
printInfo(`GOOGLE_GEMINI_BASE_URL: ${baseUrl} (root — SDK appends /v1beta)`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchGeminiModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Gemini CLI");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id>.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${configPath} ── { "model": "${model}" }`);
|
||||
} else {
|
||||
const merged = buildGeminiSettings(readJson(configPath), { model });
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Wrote ${configPath} (model)`);
|
||||
}
|
||||
|
||||
printInfo("\nThe base URL is env-only for Gemini CLI — export these:");
|
||||
console.log(buildGeminiRecipe({ baseUrl, model }));
|
||||
printInfo("\n⚠ If Gemini CLI ignores the base URL, you have a cached Google login —");
|
||||
printInfo(" run logged-out (API-key only) so GOOGLE_GEMINI_BASE_URL takes effect.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupGemini(program) {
|
||||
program
|
||||
.command("setup-gemini")
|
||||
.description("Point the Gemini CLI at OmniRoute's native Gemini /v1beta endpoint (env recipe + settings model)")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Gemini CLI (required unless picked interactively)")
|
||||
.option("--config-path <path>", "settings.json path (default: ~/.gemini/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupGeminiCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
150
bin/cli/commands/setup-goose.mjs
Normal file
150
bin/cli/commands/setup-goose.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* omniroute setup-goose — configure Goose (block/goose) for OmniRoute.
|
||||
*
|
||||
* Goose is a terminal AI agent with a file-based config at
|
||||
* ~/.config/goose/config.yaml and env-var overrides. For a custom OpenAI-
|
||||
* compatible endpoint it uses provider `openai` with OPENAI_HOST = the ROOT url
|
||||
* (NO /v1 — Goose appends the path itself). This writes the config.yaml keys and
|
||||
* prints the guaranteed env-var recipe (the API key lives in the OS keyring / env,
|
||||
* never the config file).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function stripToRoot(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s.slice(0, -3) : s;
|
||||
}
|
||||
|
||||
/** Resolve OPENAI_HOST (ROOT, no /v1 — Goose appends) + apiKey. */
|
||||
export function resolveGooseTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = stripToRoot(opts.remote);
|
||||
else {
|
||||
try {
|
||||
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { host: root, apiKey };
|
||||
}
|
||||
|
||||
/** Merge Goose's provider/model keys into config.yaml object (preserve the rest). */
|
||||
export function buildGooseConfig(existing, { host, model }) {
|
||||
const cfg = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
cfg.GOOSE_PROVIDER = "openai";
|
||||
cfg.GOOSE_MODEL = model;
|
||||
cfg.OPENAI_HOST = host; // ROOT — Goose appends /v1/chat/completions
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** The guaranteed env-var recipe (pure → testable). */
|
||||
export function buildGooseEnvRecipe({ host, model }) {
|
||||
return [
|
||||
"export GOOSE_PROVIDER=openai",
|
||||
`export OPENAI_HOST=${host}`,
|
||||
"export OPENAI_API_KEY=$OMNIROUTE_API_KEY",
|
||||
`export GOOSE_MODEL=${model}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function readYamlSafe(yaml, path) {
|
||||
try {
|
||||
if (existsSync(path)) return yaml.load(readFileSync(path, "utf8")) || {};
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(host, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${host}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupGooseCommand(opts = {}) {
|
||||
const { host, apiKey } = resolveGooseTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
|
||||
|
||||
printHeading("OmniRoute → Goose (openai-compatible)");
|
||||
printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(host, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Goose");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id>.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const yaml = await import("js-yaml");
|
||||
const merged = buildGooseConfig(readYamlSafe(yaml, configPath), { host, model });
|
||||
const out = yaml.dump(merged, { lineWidth: -1 });
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + out);
|
||||
printInfo(`[dry-run] → ${configPath}`);
|
||||
} else {
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath}`);
|
||||
}
|
||||
|
||||
printInfo("\nProvide the key (Goose reads it from the env / OS keyring):");
|
||||
console.log(buildGooseEnvRecipe({ host, model }));
|
||||
printInfo("Then run: goose session (or: goose run -t \"reply OK\")");
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupGoose(program) {
|
||||
program
|
||||
.command("setup-goose")
|
||||
.description("Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Goose (required unless picked interactively)")
|
||||
.option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupGooseCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
178
bin/cli/commands/setup-kilo.mjs
Normal file
178
bin/cli/commands/setup-kilo.mjs
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* omniroute setup-kilo — configure Kilo Code to use OmniRoute.
|
||||
*
|
||||
* Kilo Code (kilocode.kilo-code, a Cline/Roo descendant) has two surfaces:
|
||||
* - CLI/standalone mode reads ~/.local/share/kilo/auth.json.
|
||||
* - The VS Code extension reads `kilocode.*` keys from VS Code settings.json.
|
||||
* This writes BOTH (matching the OmniRoute dashboard) and prints the UI settings.
|
||||
*
|
||||
* Unlike Cline, Kilo's openAi baseURL INCLUDES /v1 (it appends /chat/completions).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
/** Ensure the URL ends with /v1 (Kilo appends /chat/completions to it). */
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveKiloTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** Merge the OmniRoute openai-compatible provider into Kilo's CLI auth.json. */
|
||||
export function buildKiloAuth(existing, { apiKey, baseUrl, model }) {
|
||||
const auth = { ...(existing || {}) };
|
||||
auth["openai-compatible"] = {
|
||||
...(auth["openai-compatible"] || {}),
|
||||
apiKey: apiKey || "sk_omniroute",
|
||||
baseUrl,
|
||||
model,
|
||||
};
|
||||
return auth;
|
||||
}
|
||||
|
||||
/** Merge the kilocode.* keys into VS Code settings.json (extension surface). */
|
||||
export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) {
|
||||
const s = { ...(existing || {}) };
|
||||
s["kilocode.customProvider"] = { name: "OmniRoute", baseURL: baseUrl, apiKey: apiKey || "sk_omniroute" };
|
||||
s["kilocode.defaultModel"] = model;
|
||||
return s;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(root, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${root.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupKiloCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveKiloTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const authPath = opts.authPath ?? opts["auth-path"] ?? join(os.homedir(), ".local", "share", "kilo", "auth.json");
|
||||
const vscodePath =
|
||||
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Kilo Code (OpenAI-compatible)");
|
||||
printInfo(`Server: ${baseUrl}`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Kilo");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id> (Kilo's extension has no model auto-discovery).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const auth = buildKiloAuth(readJson(authPath), { apiKey, baseUrl, model });
|
||||
// Only touch VS Code settings.json if it already exists (avoid creating a
|
||||
// bogus one for users who don't use that VS Code variant).
|
||||
const vscodeExists = existsSync(vscodePath);
|
||||
const vscodeSettings = vscodeExists
|
||||
? buildKiloVscodeSettings(readJson(vscodePath), { apiKey, baseUrl, model })
|
||||
: null;
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${authPath} ──`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ "openai-compatible": { ...auth["openai-compatible"], apiKey: apiKey ? "set" : "sk_omniroute" } },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`);
|
||||
} else {
|
||||
mkdirSync(join(authPath, ".."), { recursive: true });
|
||||
writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Wrote ${authPath}`);
|
||||
if (vscodeSettings) {
|
||||
writeFileSync(vscodePath, JSON.stringify(vscodeSettings, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Updated ${vscodePath} (kilocode.customProvider + defaultModel)`);
|
||||
} else {
|
||||
printInfo(`Skipped VS Code settings (${vscodePath} not found).`);
|
||||
}
|
||||
}
|
||||
|
||||
printInfo("\nFor the Kilo Code VS Code extension, set Settings → Providers → OpenAI Compatible:");
|
||||
printInfo(` Base URL: ${baseUrl} (Kilo expects /v1)`);
|
||||
printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
|
||||
printInfo(` Model: ${model}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupKilo(program) {
|
||||
program
|
||||
.command("setup-kilo")
|
||||
.description(
|
||||
"Configure Kilo Code for OmniRoute: write ~/.local/share/kilo/auth.json (CLI) + VS Code kilocode.* settings"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Kilo (required unless picked interactively)")
|
||||
.option("--auth-path <path>", "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)")
|
||||
.option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupKiloCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import os from "node:os";
|
||||
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -254,7 +255,17 @@ function runOpenCodeAuth(providerId) {
|
||||
*/
|
||||
export async function runSetupOpenCodeCommand(opts = {}) {
|
||||
const providerId = opts.providerId || "omniroute";
|
||||
const baseURL = opts.baseURL || opts.baseUrl || "http://localhost:20128";
|
||||
// Remote-aware: explicit --remote/--base-url → active context → localhost.
|
||||
let baseURL = opts.remote || opts.baseURL || opts.baseUrl;
|
||||
if (!baseURL) {
|
||||
try {
|
||||
const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
baseURL = ctx?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
}
|
||||
if (!baseURL) baseURL = "http://localhost:20128";
|
||||
const displayName = opts.displayName || null;
|
||||
const wantsAuth = Boolean(opts.auth);
|
||||
const nonInteractive = Boolean(opts.nonInteractive);
|
||||
@@ -356,8 +367,11 @@ export function registerSetupOpenCode(setupCommand) {
|
||||
)
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"OmniRoute base URL the plugin should talk to (default: http://localhost:20128)",
|
||||
"http://localhost:20128"
|
||||
"OmniRoute base URL the plugin should talk to (default: active context or http://localhost:20128)"
|
||||
)
|
||||
.option(
|
||||
"--remote <url>",
|
||||
"Remote OmniRoute URL, e.g. http://192.168.0.15:20128 (overrides --base-url and the context)"
|
||||
)
|
||||
.option("--display-name <name>", "Display name in the OpenCode UI (optional)")
|
||||
.option(
|
||||
@@ -376,6 +390,7 @@ export function registerSetupOpenCode(setupCommand) {
|
||||
output: globalOpts.output,
|
||||
apiKey: opts.apiKey ?? globalOpts.apiKey,
|
||||
baseUrl: opts.baseUrl ?? globalOpts.baseUrl,
|
||||
context: globalOpts.context ?? opts.context,
|
||||
};
|
||||
const { exitCode } = await runSetupOpenCodeCommand(merged);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
|
||||
129
bin/cli/commands/setup-opencode.mjs
Normal file
129
bin/cli/commands/setup-opencode.mjs
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* omniroute setup-opencode — Remote-aware OpenCode provider generator
|
||||
* (openai-compatible). Distinct from `omniroute setup opencode` (which wires the
|
||||
* @omniroute/opencode-plugin). This writes the `omniroute` provider into
|
||||
* ~/.config/opencode/opencode.json with every catalog model, so you can run
|
||||
* `opencode -m omniroute/<model>`.
|
||||
*
|
||||
* Reuses the proven server-side generator (config-generator/opencode.ts) for the
|
||||
* catalog fetch + merge, then references the API key by env var (never on disk).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}";
|
||||
|
||||
/** Resolve baseUrl + (literal) apiKey from flags → active context → localhost. */
|
||||
export function resolveOpencodeTarget(opts = {}) {
|
||||
let baseUrl;
|
||||
if (opts.remote) {
|
||||
baseUrl = String(opts.remote).replace(/\/+$/, "");
|
||||
} else {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
baseUrl = c?.baseUrl;
|
||||
} catch {
|
||||
/* no context */
|
||||
}
|
||||
if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process the generator output: reference the API key by env var (keep the
|
||||
* secret off disk) and optionally keep only models whose id matches `only`.
|
||||
* Pure + testable. Returns the final JSON string.
|
||||
*
|
||||
* @param {string} rawJson output of generateOpencodeConfig
|
||||
* @param {{ only?: string[] }} [opts]
|
||||
* @returns {{ json: string, modelCount: number }}
|
||||
*/
|
||||
export function postProcessOpencodeConfig(rawJson, opts = {}) {
|
||||
const config = JSON.parse(rawJson);
|
||||
const prov = config.provider?.omniroute;
|
||||
if (prov?.options) prov.options.apiKey = ENV_KEY_REF;
|
||||
|
||||
if (opts.only && opts.only.length && prov?.models) {
|
||||
const kept = {};
|
||||
for (const [id, entry] of Object.entries(prov.models)) {
|
||||
if (opts.only.some((f) => id.includes(f))) kept[id] = entry;
|
||||
}
|
||||
prov.models = kept;
|
||||
}
|
||||
const modelCount = prov?.models ? Object.keys(prov.models).length : 0;
|
||||
return { json: JSON.stringify(config, null, 2) + "\n", modelCount };
|
||||
}
|
||||
|
||||
export async function runSetupOpencodeCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveOpencodeTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
|
||||
|
||||
printHeading("OmniRoute → OpenCode provider (openai-compatible)");
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
|
||||
// Deferred import: opencode.ts is TypeScript; tsx is registered by
|
||||
// bin/omniroute.mjs before any command runs, so importing here is safe.
|
||||
let raw;
|
||||
try {
|
||||
const { generateOpencodeConfig } = await import(
|
||||
"../../../src/lib/cli-helper/config-generator/opencode.ts"
|
||||
);
|
||||
raw = await generateOpencodeConfig({ baseUrl, apiKey, model: opts.model, providerId: "omniroute" });
|
||||
} catch (err) {
|
||||
printError(`Failed to generate opencode.json: ${err?.message || err}`);
|
||||
printInfo("Make sure OmniRoute is running and --remote/--api-key are correct.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { json, modelCount } = postProcessOpencodeConfig(raw, { only });
|
||||
const configDir = join(os.homedir(), ".config", "opencode");
|
||||
const configPath = join(configDir, "opencode.json");
|
||||
|
||||
if (dryRun) {
|
||||
console.log(json.length > 4000 ? json.slice(0, 4000) + "\n… (truncated)" : json);
|
||||
printInfo(`[dry-run] ${modelCount} model(s) under provider 'omniroute' → ${configPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(configPath, json, "utf8");
|
||||
printSuccess(`opencode.json updated at ${configPath} (${modelCount} models under 'omniroute')`);
|
||||
printInfo('Use it: opencode -m omniroute/<model> "..." (export OMNIROUTE_API_KEY first)');
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupOpencode(program) {
|
||||
program
|
||||
.command("setup-opencode")
|
||||
.description(
|
||||
"Generate the OmniRoute openai-compatible provider in ~/.config/opencode/opencode.json " +
|
||||
"from the live model catalog (local or remote VPS)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Set the default top-level model (omniroute/<id>)")
|
||||
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupOpencodeCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
149
bin/cli/commands/setup-qwen.mjs
Normal file
149
bin/cli/commands/setup-qwen.mjs
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* omniroute setup-qwen — configure Qwen Code (QwenLM/qwen-code) for OmniRoute.
|
||||
*
|
||||
* Qwen Code is a terminal AI agent (gemini-cli fork) with a file-based config at
|
||||
* ~/.qwen/settings.json. For a custom OpenAI-compatible endpoint it uses a
|
||||
* `modelProviders` entry with authType "openai", baseUrl WITH /v1, and an
|
||||
* `envKey` naming the env var holding the key (secret stays in the env, never the
|
||||
* file). Remote-aware; headless test via `qwen -p "..."`.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveQwenTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** Merge the OmniRoute modelProvider into Qwen's settings.json (preserve rest). */
|
||||
export function buildQwenSettings(existing, { baseUrl, model }) {
|
||||
const s = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
const providers = Array.isArray(s.modelProviders) ? s.modelProviders.filter((p) => p?.id !== "omniroute") : [];
|
||||
providers.push({
|
||||
id: "omniroute",
|
||||
name: "OmniRoute",
|
||||
authType: "openai",
|
||||
baseUrl,
|
||||
envKey: "OMNIROUTE_API_KEY",
|
||||
});
|
||||
s.modelProviders = providers;
|
||||
if (model) {
|
||||
s.selectedProvider = "omniroute";
|
||||
s.model = model;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupQwenCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveQwenTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".qwen", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Qwen Code (openai-compatible)");
|
||||
printInfo(`baseUrl: ${baseUrl}`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Qwen");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id>.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const merged = buildQwenSettings(readJson(configPath), { baseUrl, model });
|
||||
const out = JSON.stringify(merged, null, 2) + "\n";
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n" + out);
|
||||
printInfo(`[dry-run] → ${configPath}`);
|
||||
} else {
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath}`);
|
||||
}
|
||||
printInfo("\nProvide the key (settings reference OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=...");
|
||||
printInfo('Then run: qwen (or headless: qwen -p "reply OK")');
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupQwen(program) {
|
||||
program
|
||||
.command("setup-qwen")
|
||||
.description("Configure Qwen Code for OmniRoute: write ~/.qwen/settings.json (openai modelProvider)")
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Qwen (required unless picked interactively)")
|
||||
.option("--config-path <path>", "settings.json path (default: ~/.qwen/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupQwenCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
172
bin/cli/commands/setup-roo.mjs
Normal file
172
bin/cli/commands/setup-roo.mjs
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* omniroute setup-roo — configure Roo Code (RooVeterinaryInc.roo-cline) for OmniRoute.
|
||||
*
|
||||
* Roo is a VS Code extension (Cline fork). Its live settings live in opaque VS
|
||||
* Code globalStorage, but Roo supports **Settings Import** + an
|
||||
* `roo-cline.autoImportSettingsPath` (VS Code settings.json) that loads a JSON on
|
||||
* startup. So this writes a best-effort import file + wires autoImport (when a VS
|
||||
* Code settings.json exists) + prints the UI steps as the guaranteed path.
|
||||
*
|
||||
* OpenAI-compatible: baseUrl WITH /v1 (Roo appends /chat/completions). The model
|
||||
* must support native OpenAI tool-calling (OmniRoute does).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveRooTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
/** Build a Roo Settings-Import document (provider profile, openai-compatible). */
|
||||
export function buildRooImport({ baseUrl, apiKey, model }) {
|
||||
return {
|
||||
providerProfiles: {
|
||||
currentApiConfigName: "OmniRoute",
|
||||
apiConfigs: {
|
||||
OmniRoute: {
|
||||
apiProvider: "openai",
|
||||
openAiBaseUrl: baseUrl,
|
||||
openAiApiKey: apiKey || "sk_omniroute",
|
||||
openAiModelId: model,
|
||||
openAiCustomModelInfo: { supportsImages: false, supportsPromptCache: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Add the autoImport pointer to a VS Code settings.json object. */
|
||||
export function buildRooVscodeAutoImport(existing, importPath) {
|
||||
return { ...(existing || {}), "roo-cline.autoImportSettingsPath": importPath };
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSetupRooCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveRooTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const importPath = opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json");
|
||||
const vscodePath =
|
||||
opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Roo Code (OpenAI-compatible)");
|
||||
printInfo(`Server: ${baseUrl}`);
|
||||
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const { createPrompt } = await import("../io.mjs");
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Roo");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id> (Roo has no model auto-discovery).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const importDoc = buildRooImport({ baseUrl, apiKey, model });
|
||||
const vscodeExists = existsSync(vscodePath);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${importPath} ──`);
|
||||
console.log(JSON.stringify({ ...importDoc, providerProfiles: { ...importDoc.providerProfiles, apiConfigs: { OmniRoute: { ...importDoc.providerProfiles.apiConfigs.OmniRoute, openAiApiKey: apiKey ? "set" : "sk_omniroute" } } } }, null, 2));
|
||||
console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`);
|
||||
} else {
|
||||
mkdirSync(join(importPath, ".."), { recursive: true });
|
||||
writeFileSync(importPath, JSON.stringify(importDoc, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Wrote ${importPath}`);
|
||||
if (vscodeExists) {
|
||||
const merged = buildRooVscodeAutoImport(readJson(vscodePath), importPath);
|
||||
writeFileSync(vscodePath, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
||||
printSuccess(`Set roo-cline.autoImportSettingsPath in ${vscodePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
printInfo("\nIn the Roo Code panel: Settings → Providers → OpenAI Compatible (guaranteed path):");
|
||||
printInfo(` Base URL: ${baseUrl} (Roo expects /v1)`);
|
||||
printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
|
||||
printInfo(` Model: ${model}`);
|
||||
printInfo(`Or use Roo: “Import Settings” → select ${importPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupRoo(program) {
|
||||
program
|
||||
.command("setup-roo")
|
||||
.description(
|
||||
"Configure Roo Code for OmniRoute: write a Roo import JSON + autoImport pointer + print UI steps"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
|
||||
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
|
||||
.option("--model <id>", "Model id for Roo (required unless picked interactively)")
|
||||
.option("--import-path <path>", "Roo import JSON path (default: ~/.omniroute/roo-settings.json)")
|
||||
.option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupRooCommand(opts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
118
bin/cli/commands/tokens.mjs
Normal file
118
bin/cli/commands/tokens.mjs
Normal file
@@ -0,0 +1,118 @@
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { printSuccess, printError, printInfo } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
/**
|
||||
* `omniroute tokens` — manage scoped CLI access tokens on the active (usually
|
||||
* remote) server. Requires an `admin` credential — the commands hit
|
||||
* /api/cli/tokens which is admin-only. Uses the active context's auth via
|
||||
* apiFetch automatically.
|
||||
*/
|
||||
|
||||
async function readErrorMessage(res) {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.error?.message || body?.error || `HTTP ${res.status}`;
|
||||
} catch {
|
||||
return `HTTP ${res.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTokens(program) {
|
||||
const tokens = program
|
||||
.command("tokens")
|
||||
.description(t("tokens.description") || "Manage scoped CLI access tokens (remote mode)");
|
||||
|
||||
tokens
|
||||
.command("create")
|
||||
.description("Create a new access token (requires admin scope)")
|
||||
.requiredOption("--name <name>", "Human-readable token name")
|
||||
.option("--scope <scope>", "Scope: read | write | admin", "read")
|
||||
.option("--expires <days>", "Expire after N days (default: never)")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const body = { name: opts.name, scope: opts.scope };
|
||||
if (opts.expires) {
|
||||
const days = Number(opts.expires);
|
||||
if (!Number.isFinite(days) || days <= 0) {
|
||||
printError("--expires must be a positive number of days.");
|
||||
process.exit(2);
|
||||
}
|
||||
body.expiresInDays = days;
|
||||
}
|
||||
const res = await apiFetch("/api/cli/tokens", {
|
||||
...globalOpts,
|
||||
method: "POST",
|
||||
body,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Could not create token: ${await readErrorMessage(res)}`);
|
||||
process.exit(res.exitCode || 1);
|
||||
}
|
||||
const b = await res.json();
|
||||
printSuccess(`Token '${b.name}' created (scope: ${b.scope}).`);
|
||||
printInfo("Copy it now — it will NOT be shown again:");
|
||||
process.stdout.write(`${b.token}\n`);
|
||||
});
|
||||
|
||||
tokens
|
||||
.command("list")
|
||||
.description("List access tokens (masked)")
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const res = await apiFetch("/api/cli/tokens", { ...globalOpts, acceptNotOk: true });
|
||||
if (!res.ok) {
|
||||
printError(`Could not list tokens: ${await readErrorMessage(res)}`);
|
||||
process.exit(res.exitCode || 1);
|
||||
}
|
||||
const b = await res.json();
|
||||
const rows = (b.tokens || []).map((tk) => ({
|
||||
id: tk.id,
|
||||
name: tk.name,
|
||||
scope: tk.scope,
|
||||
prefix: tk.tokenPrefix,
|
||||
created: tk.createdAt,
|
||||
lastUsed: tk.lastUsedAt || "",
|
||||
expires: tk.expiresAt || "",
|
||||
status: tk.revokedAt ? "revoked" : "active",
|
||||
}));
|
||||
emit(rows, globalOpts, [
|
||||
{ key: "id", header: "ID" },
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "scope", header: "Scope" },
|
||||
{ key: "prefix", header: "Prefix" },
|
||||
{ key: "status", header: "Status" },
|
||||
{ key: "lastUsed", header: "Last Used" },
|
||||
{ key: "expires", header: "Expires" },
|
||||
]);
|
||||
});
|
||||
|
||||
tokens
|
||||
.command("revoke <idOrPrefix>")
|
||||
.description("Revoke an access token by id or display prefix")
|
||||
.action(async (idOrPrefix, opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const res = await apiFetch(`/api/cli/tokens/${encodeURIComponent(idOrPrefix)}`, {
|
||||
...globalOpts,
|
||||
method: "DELETE",
|
||||
acceptNotOk: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
printError(`Could not revoke token: ${await readErrorMessage(res)}`);
|
||||
process.exit(res.exitCode || 1);
|
||||
}
|
||||
printSuccess(`Revoked ${idOrPrefix}.`);
|
||||
});
|
||||
|
||||
tokens
|
||||
.command("scopes")
|
||||
.description("Explain the three access-token scopes")
|
||||
.action(() => {
|
||||
printInfo("Access-token scopes (admin ⊃ write ⊃ read):");
|
||||
process.stdout.write(" read list/inspect only (models, status, logs, usage)\n");
|
||||
process.stdout.write(" write read + configure/apply (setup-codex, keys add, config set)\n");
|
||||
process.stdout.write(" admin write + manage (tokens, providers add, services, policy)\n");
|
||||
});
|
||||
}
|
||||
@@ -25,9 +25,13 @@ export async function getCurrentVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getLatestVersion() {
|
||||
// `--prefer-online` forces npm to revalidate its HTTP cache against the registry.
|
||||
// Without it `npm view` can return a stale cached version (e.g. report 3.8.30 as
|
||||
// "latest" after 3.8.31 was published), so the updater told users on an old build
|
||||
// they were already on the latest version (#4376). `execFn` is injectable for tests.
|
||||
export async function getLatestVersion(execFn = execFileAsync) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("npm", ["view", "omniroute", "version"], {
|
||||
const { stdout } = await execFn("npm", ["view", "omniroute", "version", "--prefer-online"], {
|
||||
timeout: 15000,
|
||||
});
|
||||
return stdout.trim();
|
||||
@@ -142,7 +146,7 @@ export async function runUpdateCommand(opts = {}) {
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log("\n [DRY RUN] Would run: npm install -g omniroute@latest");
|
||||
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;
|
||||
}
|
||||
@@ -174,7 +178,9 @@ export async function runUpdateCommand(opts = {}) {
|
||||
printInfo("Updating OmniRoute...");
|
||||
try {
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("npm install -g omniroute@latest", { stdio: "inherit" });
|
||||
// --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", { stdio: "inherit" });
|
||||
printSuccess(`Updated to version ${latest}`);
|
||||
printInfo("Run `omniroute --version` to verify.");
|
||||
return 0;
|
||||
|
||||
@@ -36,8 +36,25 @@ export function saveContexts(cfg) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active context for a CLI invocation.
|
||||
*
|
||||
* Canonical schema is `{ currentContext, contexts }` (written by
|
||||
* `omniroute contexts ...`). For backward compatibility we also read the legacy
|
||||
* `{ activeProfile, profiles }` shape and a bare top-level `baseUrl` — older
|
||||
* configs and `api.mjs::getBaseUrl` used those before remote-mode unified the
|
||||
* store. `overrideName` (from `--context`/`OMNIROUTE_CONTEXT`) wins when set.
|
||||
*
|
||||
* A context may carry `{ baseUrl, accessToken?, apiKey?, scope?, description? }`.
|
||||
* `accessToken` is the scoped CLI access token (preferred); `apiKey` is the
|
||||
* legacy inference key kept for back-compat.
|
||||
*/
|
||||
export function resolveActiveContext(overrideName) {
|
||||
const cfg = loadContexts();
|
||||
const name = overrideName || cfg.currentContext || "default";
|
||||
return cfg.contexts?.[name] || cfg.contexts?.default || { baseUrl: "http://localhost:20128" };
|
||||
const contexts = cfg.contexts || cfg.profiles || {};
|
||||
const name = overrideName || cfg.currentContext || cfg.activeProfile || "default";
|
||||
const found = contexts[name] || contexts.default;
|
||||
if (found) return found;
|
||||
if (cfg.baseUrl) return { baseUrl: cfg.baseUrl };
|
||||
return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` };
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user