mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 07:32:20 +03:00
Compare commits
196 Commits
radar-expo
...
fix/10986-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9493a53edd | ||
|
|
3caa59107e | ||
|
|
9469b9c79e | ||
|
|
4ac157b76d | ||
|
|
8f0d0a0d03 | ||
|
|
00dfdadf93 | ||
|
|
efece42fec | ||
|
|
f24226f219 | ||
|
|
137e49e393 | ||
|
|
9459546621 | ||
|
|
0fffb4548e | ||
|
|
fefca17762 | ||
|
|
143fd78a1a | ||
|
|
3f0b4caa98 | ||
|
|
30e20c6b60 | ||
|
|
eba58cc8b4 | ||
|
|
79e1a8f9c6 | ||
|
|
464ccb3142 | ||
|
|
1f4bde1817 | ||
|
|
bc0a272bfc | ||
|
|
8d076327f1 | ||
|
|
054cfae044 | ||
|
|
48addd51df | ||
|
|
a492d6d780 | ||
|
|
effc542a6a | ||
|
|
60829241fd | ||
|
|
c130f2aa1c | ||
|
|
089a5e7cab | ||
|
|
ad3f7bc008 | ||
|
|
154c2945d1 | ||
|
|
63c0125c1e | ||
|
|
d1e5a572dd | ||
|
|
1f09e2f9b3 | ||
|
|
1c1e45c2a1 | ||
|
|
28924fe05b | ||
|
|
6ec6940314 | ||
|
|
e708030adf | ||
|
|
14d2c90e23 | ||
|
|
7011c5fafa | ||
|
|
a8ca9575f2 | ||
|
|
0f35febd24 | ||
|
|
410a061eaf | ||
|
|
53608c8cb4 | ||
|
|
6efb01a957 | ||
|
|
65dcb1d1ed | ||
|
|
6663d70004 | ||
|
|
c0fd109e30 | ||
|
|
10deb5c307 | ||
|
|
4c0b54abc1 | ||
|
|
9b952829e0 | ||
|
|
31193afc02 | ||
|
|
e4a24173af | ||
|
|
4fa204de68 | ||
|
|
6eaa737333 | ||
|
|
9fd19f5522 | ||
|
|
0940bb6082 | ||
|
|
8b9dc7ddfb | ||
|
|
354d3a2741 | ||
|
|
467a80428c | ||
|
|
788be6d2e2 | ||
|
|
1b16e1276f | ||
|
|
fdeac496e8 | ||
|
|
825e2ab3ab | ||
|
|
3805494250 | ||
|
|
45b42ecdee | ||
|
|
967b56a0dc | ||
|
|
fac2a93dbf | ||
|
|
b580992205 | ||
|
|
24f31cf4e0 | ||
|
|
7fa65288aa | ||
|
|
1d2918807d | ||
|
|
34f65e1354 | ||
|
|
af47a5f2e4 | ||
|
|
5bf694f0b3 | ||
|
|
6fd4040148 | ||
|
|
16fc433a4f | ||
|
|
fa0cd5af1c | ||
|
|
6098954b0b | ||
|
|
b661b71559 | ||
|
|
b87056a345 | ||
|
|
1d4c4cd7c9 | ||
|
|
374b387b35 | ||
|
|
d6737bfa2a | ||
|
|
74c9e7e0d2 | ||
|
|
d098114fe5 | ||
|
|
cbf23772ec | ||
|
|
28788cb9af | ||
|
|
00aba8ef16 | ||
|
|
769ab62fa3 | ||
|
|
2cd14b1696 | ||
|
|
f71f3da08b | ||
|
|
118840131d | ||
|
|
3fed9e837a | ||
|
|
1f04e73a1c | ||
|
|
f66c986dbd | ||
|
|
3669df2ca5 | ||
|
|
590cbbe7b1 | ||
|
|
c7e264e1a6 | ||
|
|
699be22e1e | ||
|
|
7756aef970 | ||
|
|
050c7c0021 | ||
|
|
c535df9076 | ||
|
|
5d9998eb31 | ||
|
|
19741775ee | ||
|
|
b668d91364 | ||
|
|
4ec080dc19 | ||
|
|
0458c5ac4c | ||
|
|
9603ec1bf1 | ||
|
|
6d043674c2 | ||
|
|
bed4d24049 | ||
|
|
018badc3b3 | ||
|
|
87719f2381 | ||
|
|
0a1f1d42ee | ||
|
|
eb6f319712 | ||
|
|
bc9090ba65 | ||
|
|
d9cb4f5f5d | ||
|
|
ce6249cbb7 | ||
|
|
9935f80971 | ||
|
|
e968d11b1c | ||
|
|
7afafcecc9 | ||
|
|
c79faa45fb | ||
|
|
871832820f | ||
|
|
d87b97a786 | ||
|
|
25ba4f2a34 | ||
|
|
c40ff16a1d | ||
|
|
0b51a242ce | ||
|
|
7f90af645c | ||
|
|
e6801bace1 | ||
|
|
82ed31d27a | ||
|
|
dacf4c3c1a | ||
|
|
362c5acbfe | ||
|
|
7fd82eb146 | ||
|
|
6f28688b04 | ||
|
|
12d0acbe06 | ||
|
|
61051a1460 | ||
|
|
1fb466a1ee | ||
|
|
2c84ce19df | ||
|
|
e5b7c40d11 | ||
|
|
053c64d380 | ||
|
|
3112304db6 | ||
|
|
a280bfc112 | ||
|
|
d99701d6b3 | ||
|
|
80b517edea | ||
|
|
a72dc25c04 | ||
|
|
621f30a188 | ||
|
|
ff9a4c2fbd | ||
|
|
f52fa9dc85 | ||
|
|
424b950856 | ||
|
|
01b3828278 | ||
|
|
998c3c2129 | ||
|
|
b59a88b7eb | ||
|
|
54b39690e5 | ||
|
|
22e46a0875 | ||
|
|
2a10d16114 | ||
|
|
06315c445c | ||
|
|
31031f93ef | ||
|
|
c6a0d09bcd | ||
|
|
b052c91014 | ||
|
|
bbcfb730ca | ||
|
|
db7c3abaf6 | ||
|
|
567b9db04d | ||
|
|
8bc2f0f10c | ||
|
|
8c4a219746 | ||
|
|
7288fa0dd7 | ||
|
|
d14a4d2da1 | ||
|
|
2acafd9c9e | ||
|
|
9d2240eab7 | ||
|
|
9eddafff60 | ||
|
|
7c6bf32186 | ||
|
|
56b9d00335 | ||
|
|
2f7315882b | ||
|
|
77d75022d6 | ||
|
|
6f08a089e7 | ||
|
|
8b52596d7c | ||
|
|
6767f27011 | ||
|
|
7ac6bbba37 | ||
|
|
84d7e33c26 | ||
|
|
80a59c0ae5 | ||
|
|
8122f6b71c | ||
|
|
5e508147c4 | ||
|
|
a352c23bad | ||
|
|
4821f9ffdb | ||
|
|
f4772500bc | ||
|
|
b43ad73166 | ||
|
|
bb98e9a345 | ||
|
|
ff8b7b172f | ||
|
|
0bfaaa4929 | ||
|
|
4c15c05f9b | ||
|
|
8cd248b4f5 | ||
|
|
05a37634c2 | ||
|
|
62f6e87869 | ||
|
|
74c54828fc | ||
|
|
f060117464 | ||
|
|
9fc3b29217 | ||
|
|
e9dd87ad77 | ||
|
|
142ae93498 |
97
.env.example
97
.env.example
@@ -229,6 +229,15 @@ PORT=20128
|
|||||||
# unaffected by this dev-only flag).
|
# unaffected by this dev-only flag).
|
||||||
OMNIROUTE_USE_TURBOPACK=1
|
OMNIROUTE_USE_TURBOPACK=1
|
||||||
|
|
||||||
|
# Disable systemd sd_notify (Type=notify / WatchdogSec=) even when running
|
||||||
|
# under a systemd unit with NOTIFY_SOCKET set.
|
||||||
|
# Used by: scripts/dev/systemd-notify.mjs. Set to 1 to disable.
|
||||||
|
# OMNIROUTE_DISABLE_SD_NOTIFY=1
|
||||||
|
|
||||||
|
# Injected by systemd when running under a service unit (sd_notify protocol).
|
||||||
|
# Read by scripts/dev/systemd-notify.mjs — never set this yourself.
|
||||||
|
# NOTIFY_SOCKET=/run/systemd/notify
|
||||||
|
|
||||||
# Skip the SQLite integrity health check on startup (faster boot on large DBs).
|
# 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.
|
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip.
|
||||||
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
|
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
|
||||||
@@ -408,6 +417,15 @@ ALLOW_API_KEY_REVEAL=false
|
|||||||
# by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive
|
# by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive
|
||||||
# value only on memory-constrained deployments that need a hard ceiling.
|
# value only on memory-constrained deployments that need a hard ceiling.
|
||||||
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0
|
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0
|
||||||
|
|
||||||
|
# Skip OmniRoute's local context-window and max-input-token check for direct
|
||||||
|
# single-model requests. Default: false (dangerous opt-in).
|
||||||
|
# The upstream provider still enforces its real limits, so enabling this can
|
||||||
|
# replace an early OmniRoute 400 with an upstream context-length error.
|
||||||
|
# Prompt compression and the model's own output-token cap remain active.
|
||||||
|
# Also configurable from Dashboard > Settings > Feature Flags; no restart is
|
||||||
|
# required. Used by: src/shared/utils/featureFlags.ts and open-sse/handlers/chatCore.ts.
|
||||||
|
# DISABLE_CONTEXT_WINDOW_CHECKS=false
|
||||||
# How long a heavy request waits for heavyweight capacity before a retryable 503.
|
# How long a heavy request waits for heavyweight capacity before a retryable 503.
|
||||||
# A short bounded wait serializes agent bursts instead of an instant 503; 0 = instant.
|
# A short bounded wait serializes agent bursts instead of an instant 503; 0 = instant.
|
||||||
# Default 2000 (2s).
|
# Default 2000 (2s).
|
||||||
@@ -696,6 +714,11 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
|||||||
# ALL_PROXY=socks5://127.0.0.1:7890
|
# ALL_PROXY=socks5://127.0.0.1:7890
|
||||||
# NO_PROXY=localhost,127.0.0.1
|
# NO_PROXY=localhost,127.0.0.1
|
||||||
|
|
||||||
|
# Pin the echo-IP target used by proxy egress probes. Unset, the probe tries
|
||||||
|
# api64.ipify.org then api4.ipify.org so IPv4-only tunnels are not reported dead.
|
||||||
|
# Used by: src/lib/proxyEchoTarget.ts.
|
||||||
|
# OMNIROUTE_PROXY_ECHO_URL=https://api4.ipify.org?format=json
|
||||||
|
|
||||||
# Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher.
|
# Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher.
|
||||||
# Long-lived SSE streams such as Codex /v1/responses need more than one
|
# Long-lived SSE streams such as Codex /v1/responses need more than one
|
||||||
# connection when multiple requests share the same account-level proxy.
|
# connection when multiple requests share the same account-level proxy.
|
||||||
@@ -876,13 +899,21 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
|||||||
# Set to 0/false/off to skip compression entirely. Default: rtk
|
# Set to 0/false/off to skip compression entirely. Default: rtk
|
||||||
# OMNIROUTE_MCP_DESCRIPTION_COMPRESSION=rtk
|
# OMNIROUTE_MCP_DESCRIPTION_COMPRESSION=rtk
|
||||||
|
|
||||||
|
# Abort budget (ms) for MCP-server internal management reads (health, resilience,
|
||||||
|
# combos, quota, usage). Default: 10000. Used by: open-sse/mcp-server/fetchTimeout.ts
|
||||||
|
# OMNIROUTE_MCP_FETCH_TIMEOUT_MS=10000
|
||||||
|
|
||||||
|
# Abort budget (ms) for MCP hops that wait on a provider (route_request, web_search,
|
||||||
|
# web_fetch). Default: 60000. Used by: open-sse/mcp-server/fetchTimeout.ts
|
||||||
|
# OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS=60000
|
||||||
|
|
||||||
# Model catalog sync interval in hours.
|
# Model catalog sync interval in hours.
|
||||||
# Used by: src/shared/services/modelSyncScheduler.ts — periodic model refresh.
|
# Used by: src/shared/services/modelSyncScheduler.ts — periodic model refresh.
|
||||||
# Default: 24
|
# Default: 24
|
||||||
# MODEL_SYNC_INTERVAL_HOURS=24
|
# MODEL_SYNC_INTERVAL_HOURS=24
|
||||||
|
|
||||||
# Provider limits sync interval in minutes (rate limit windows, quotas).
|
# Provider limits sync interval in minutes (rate limit windows, quotas).
|
||||||
# Used by: src/server-init.ts — polls provider health endpoints.
|
# Used by: src/lib/usage/providerLimits.ts — polls provider health endpoints.
|
||||||
# Default: 70
|
# Default: 70
|
||||||
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
|
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
|
||||||
|
|
||||||
@@ -1352,6 +1383,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
|||||||
# FETCH_BODY_TIMEOUT_MS=600000 # Time to receive full response body
|
# FETCH_BODY_TIMEOUT_MS=600000 # Time to receive full response body
|
||||||
# FETCH_CONNECT_TIMEOUT_MS=30000 # TCP connection establishment (default: 30s)
|
# FETCH_CONNECT_TIMEOUT_MS=30000 # TCP connection establishment (default: 30s)
|
||||||
# FETCH_KEEPALIVE_TIMEOUT_MS=4000 # Keep-alive socket idle timeout (default: 4s)
|
# FETCH_KEEPALIVE_TIMEOUT_MS=4000 # Keep-alive socket idle timeout (default: 4s)
|
||||||
|
# OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS=30000 # Bounded response-start window per direct
|
||||||
|
# # (no-proxy) attempt (#10214). A silently-dropped
|
||||||
|
# # pooled keep-alive socket surfaces no transport
|
||||||
|
# # error, so without this bound a direct request can
|
||||||
|
# # stall until undici's headersTimeout (600s) or the
|
||||||
|
# # caller's deadline; on expiry the request retries
|
||||||
|
# # once on a fresh no-keep-alive socket. 0 disables
|
||||||
|
# # the bound (default: 30000 = 30s).
|
||||||
|
|
||||||
# Default timeout (ms) for src/shared/utils/fetchTimeout.ts. Acts as the
|
# Default timeout (ms) for src/shared/utils/fetchTimeout.ts. Acts as the
|
||||||
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
|
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
|
||||||
@@ -1406,6 +1445,14 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
|||||||
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
|
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
|
||||||
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
|
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
|
||||||
|
|
||||||
|
# ── Perplexity web: built-in-search hint ──
|
||||||
|
# Used by: open-sse/executors/perplexity-web/protocol.ts — appends "You have
|
||||||
|
# built-in web search. Answer questions directly using search results." to the
|
||||||
|
# caller's system message. Off by default: Perplexity's answer engine searches
|
||||||
|
# anyway, and for coding clients the sentence leaks into replies as
|
||||||
|
# meta-commentary. Set to 1/true/yes/on to restore the old behavior.
|
||||||
|
# OMNIROUTE_PPLX_SEARCH_HINT=0
|
||||||
|
|
||||||
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
|
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
|
||||||
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
|
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
|
||||||
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
|
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
|
||||||
@@ -1436,6 +1483,20 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
|||||||
# OMNIROUTE_BROWSER_POOL=on
|
# OMNIROUTE_BROWSER_POOL=on
|
||||||
# WEB_COOKIE_USE_BROWSER=0
|
# WEB_COOKIE_USE_BROWSER=0
|
||||||
|
|
||||||
|
# ── Kimi Web (international kimi.ai Connect-RPC) ──
|
||||||
|
# Used by: open-sse/executors/kimi-web.ts. Override the base/chat URLs only if
|
||||||
|
# you need a mirror or proxy endpoint; defaults target https://www.kimi.ai with
|
||||||
|
# the Connect-RPC chat path /apiv2/kimi.gateway.chat.v1.ChatService/Chat.
|
||||||
|
# KIMI_WEB_BASE_URL=https://www.kimi.ai
|
||||||
|
# KIMI_WEB_CHAT_URL=https://www.kimi.ai/apiv2/kimi.gateway.chat.v1.ChatService/Chat
|
||||||
|
|
||||||
|
# When OIDC is enabled, disable password login so users can only authenticate
|
||||||
|
# via OIDC Single Sign-On. The bare alias OIDC_DISABLE_PASSWORD_LOGIN is also
|
||||||
|
# accepted; the Dashboard Feature Flag takes precedence. Used by:
|
||||||
|
# src/app/api/auth/login/route.ts, src/app/api/settings/require-login/route.ts.
|
||||||
|
# OMNIROUTE_OIDC_DISABLE_PASSWORD_LOGIN=false
|
||||||
|
# OIDC_DISABLE_PASSWORD_LOGIN=false
|
||||||
|
|
||||||
# ── Adobe Firefly browser sign-in (system Chrome/Edge CDP) ──
|
# ── Adobe Firefly browser sign-in (system Chrome/Edge CDP) ──
|
||||||
# Used by: open-sse/services/adobeFireflyBrowserLogin.ts. The Firefly login
|
# Used by: open-sse/services/adobeFireflyBrowserLogin.ts. The Firefly login
|
||||||
# flow drives a real, system-installed Chrome or Microsoft Edge via CDP so the
|
# flow drives a real, system-installed Chrome or Microsoft Edge via CDP so the
|
||||||
@@ -1880,10 +1941,6 @@ APP_LOG_TO_FILE=true
|
|||||||
# Default: 300000 (5 minutes)
|
# Default: 300000 (5 minutes)
|
||||||
# SEARCH_CACHE_TTL_MS=300000
|
# SEARCH_CACHE_TTL_MS=300000
|
||||||
|
|
||||||
# ── OpenAI-compatible multi-connection ──
|
|
||||||
# Allow multiple simultaneous connections per OpenAI-compatible provider node.
|
|
||||||
# Used by: src/app/api/providers/route.ts
|
|
||||||
# ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE=false
|
|
||||||
|
|
||||||
# ── CC-compatible provider (experimental) ──
|
# ── CC-compatible provider (experimental) ──
|
||||||
# Enable the Claude Code compatible provider endpoint.
|
# Enable the Claude Code compatible provider endpoint.
|
||||||
@@ -1979,6 +2036,16 @@ APP_LOG_TO_FILE=true
|
|||||||
# Reachability probe target for the scheduler and the auto-test endpoint.
|
# Reachability probe target for the scheduler and the auto-test endpoint.
|
||||||
# Point it at an internal/self-hosted URL to avoid the public default.
|
# Point it at an internal/self-hosted URL to avoid the public default.
|
||||||
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
|
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
|
||||||
|
# Probes started at once per batch, for the scheduler and the auto-test endpoint.
|
||||||
|
# Floored at 1 and capped at 50. Default: 10.
|
||||||
|
# PROXY_HEALTH_TEST_CONCURRENCY=10
|
||||||
|
# Delay in ms between two probe departures inside a batch. Without it the whole batch
|
||||||
|
# leaves at once and a shared egress IP can trip a rate-limited target. 0 disables the
|
||||||
|
# spacing; capped at 5000. Default: 100.
|
||||||
|
# PROXY_HEALTH_TEST_STAGGER_MS=100
|
||||||
|
# Set "false" to stop probing the real host of a proxy's assigned provider (GET /models,
|
||||||
|
# no API key) and always use the generic target above instead. Default: enabled.
|
||||||
|
# PROXY_HEALTH_USE_PROVIDER_TARGET=true
|
||||||
# Set "true" to let the scheduler auto-remove proxies after repeated failures.
|
# Set "true" to let the scheduler auto-remove proxies after repeated failures.
|
||||||
# PROXY_AUTO_REMOVE=false
|
# PROXY_AUTO_REMOVE=false
|
||||||
# Consecutive failures before an auto-remove fires. Default: 3.
|
# Consecutive failures before an auto-remove fires. Default: 3.
|
||||||
@@ -2121,6 +2188,19 @@ APP_LOG_TO_FILE=true
|
|||||||
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: detect local install, else pin.
|
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: detect local install, else pin.
|
||||||
# CURSOR_AGENT_CLI_VERSION=2026.07.08-0c04a8a
|
# CURSOR_AGENT_CLI_VERSION=2026.07.08-0c04a8a
|
||||||
|
|
||||||
|
# Path to the Cursor Agent binary used for image generation.
|
||||||
|
# Used by: open-sse/handlers/imageGeneration/providers (CURSOR_IMAGE.md).
|
||||||
|
# CURSOR_AGENT_BIN=/path/to/agent
|
||||||
|
|
||||||
|
# Cursor image-generation wall clock (ms). Default: 210000.
|
||||||
|
# CURSOR_IMG_TIMEOUT_MS=210000
|
||||||
|
|
||||||
|
# Shared-seat concurrency gate for Cursor image jobs. Default: 2.
|
||||||
|
# CURSOR_IMG_MAX_CONCURRENT=2
|
||||||
|
|
||||||
|
# Override Cursor CLI --model for image jobs. Default: request model / auto.
|
||||||
|
# CURSOR_IMG_MODEL=auto
|
||||||
|
|
||||||
# Cursor Agent CLI data directory override (versions live under <dir>/versions/).
|
# Cursor Agent CLI data directory override (versions live under <dir>/versions/).
|
||||||
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: ~/.local/share/cursor-agent (unix)
|
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: ~/.local/share/cursor-agent (unix)
|
||||||
# or %LOCALAPPDATA%\cursor-agent (win32). Official agent CLI also honors this var.
|
# or %LOCALAPPDATA%\cursor-agent (win32). Official agent CLI also honors this var.
|
||||||
@@ -2494,6 +2574,11 @@ APP_LOG_TO_FILE=true
|
|||||||
# intended to be published as `omniroute-secure`. See SECURITY.md.
|
# intended to be published as `omniroute-secure`. See SECURITY.md.
|
||||||
# OMNIROUTE_BUILD_PROFILE=full
|
# OMNIROUTE_BUILD_PROFILE=full
|
||||||
|
|
||||||
|
# Override the standalone build output directory consumed by the post-build
|
||||||
|
# colocation step. Default: the real Next.js standalone output under .build/.
|
||||||
|
# Used by: scripts/build/colocate-standalone.mjs (build tooling, not runtime).
|
||||||
|
# OMNIROUTE_STANDALONE_DIR=
|
||||||
|
|
||||||
# Skip emitting `.tar.gz` tarballs during optional-pack staging for the Electron
|
# Skip emitting `.tar.gz` tarballs during optional-pack staging for the Electron
|
||||||
# standalone tree (pack directories + optional-packs.index.json are still produced).
|
# standalone tree (pack directories + optional-packs.index.json are still produced).
|
||||||
# Used by the desktop release workflow to trim artifact upload size.
|
# Used by the desktop release workflow to trim artifact upload size.
|
||||||
@@ -2508,6 +2593,8 @@ APP_LOG_TO_FILE=true
|
|||||||
# ELECTRON_SMOKE_DATA_DIR=
|
# ELECTRON_SMOKE_DATA_DIR=
|
||||||
# ELECTRON_SMOKE_KEEP_DATA=0
|
# ELECTRON_SMOKE_KEEP_DATA=0
|
||||||
# ELECTRON_SMOKE_STREAM_LOGS=0
|
# ELECTRON_SMOKE_STREAM_LOGS=0
|
||||||
|
# #7592: second launch against the same DATA_DIR must pick the native driver.
|
||||||
|
# ELECTRON_SMOKE_COLD_RESTART=0
|
||||||
|
|
||||||
# Playground Studio
|
# Playground Studio
|
||||||
# Default model used by the improve-prompt route (optional; falls back to model in request body).
|
# Default model used by the improve-prompt route (optional; falls back to model in request body).
|
||||||
|
|||||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -50,13 +50,13 @@ updates:
|
|||||||
# bumps; majors here need their own PR and a deliberate migration review.
|
# bumps; majors here need their own PR and a deliberate migration review.
|
||||||
- dependency-name: "ioredis"
|
- dependency-name: "ioredis"
|
||||||
update-types: ["version-update:semver-major"]
|
update-types: ["version-update:semver-major"]
|
||||||
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
|
# @huggingface/transformers is VPS-validated at ^4.2.0 (migrated intentionally in
|
||||||
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
|
# #9962). It is load-bearing for the LLMLingua ONNX compression engine (open-sse/
|
||||||
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
|
# services/compression/engines/llmlingua/ — @atjsh/llmlingua-2@2.0.5 peers on
|
||||||
# and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts),
|
# "@huggingface/transformers": "^3.5.2 || ^4.0.0") and for local memory embeddings
|
||||||
# and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must
|
# (src/lib/memory/embedding/transformersLocal.ts). Further majors must be re-validated
|
||||||
# be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore
|
# on the VPS — so keep auto-bumps frozen (no update-types = ignore every version).
|
||||||
# every version). Migrate it intentionally, not via dependabot (#4050).
|
# Migrate it intentionally, not via dependabot (#4050).
|
||||||
- dependency-name: "@huggingface/transformers"
|
- dependency-name: "@huggingface/transformers"
|
||||||
|
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
|
|||||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
|||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
- uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||||
with:
|
with:
|
||||||
languages: javascript-typescript
|
languages: javascript-typescript
|
||||||
queries: security-extended
|
queries: security-extended
|
||||||
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
- uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||||
with:
|
with:
|
||||||
category: "/language:javascript-typescript"
|
category: "/language:javascript-typescript"
|
||||||
|
|||||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -372,7 +372,7 @@ jobs:
|
|||||||
- name: Upload Trivy SARIF to Security tab
|
- name: Upload Trivy SARIF to Security tab
|
||||||
if: needs.prepare.outputs.version != 'main'
|
if: needs.prepare.outputs.version != 'main'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: github/codeql-action/upload-sarif@v4.37.6
|
uses: github/codeql-action/upload-sarif@v4.37.7
|
||||||
with:
|
with:
|
||||||
sarif_file: trivy-results.sarif
|
sarif_file: trivy-results.sarif
|
||||||
category: trivy-image
|
category: trivy-image
|
||||||
|
|||||||
5
.github/workflows/electron-release.yml
vendored
5
.github/workflows/electron-release.yml
vendored
@@ -279,9 +279,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Smoke packaged Electron app (Linux)
|
- name: Smoke packaged Electron app (Linux)
|
||||||
if: matrix.platform == 'linux'
|
if: matrix.platform == 'linux'
|
||||||
|
# #7592: also cold-restart against the same DATA_DIR and assert a
|
||||||
|
# native SQLite driver (not the sql.js WASM fallback) is selected on
|
||||||
|
# the second launch — blocking here since Linux has no Windows-style
|
||||||
|
# sandbox caveats that would make it flaky.
|
||||||
env:
|
env:
|
||||||
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
||||||
ELECTRON_SMOKE_STREAM_LOGS: "1"
|
ELECTRON_SMOKE_STREAM_LOGS: "1"
|
||||||
|
ELECTRON_SMOKE_COLD_RESTART: "1"
|
||||||
run: xvfb-run -a npm run electron:smoke:packaged
|
run: xvfb-run -a npm run electron:smoke:packaged
|
||||||
|
|
||||||
- name: Collect installers
|
- name: Collect installers
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -291,3 +291,4 @@ docker-compose.yml.bak
|
|||||||
|
|
||||||
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
|
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
|
||||||
/.sandbox/
|
/.sandbox/
|
||||||
|
.aider*
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ import {
|
|||||||
type FreeModelFreeType,
|
type FreeModelFreeType,
|
||||||
} from "./naming.js";
|
} from "./naming.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal leveled logger sink accepted by the default fetchers and the static
|
||||||
|
* catalog builder. A full `Logger` satisfies it structurally; the config hook
|
||||||
|
* injects the same partial shape (see `createOmniRouteConfigHook` deps).
|
||||||
|
*/
|
||||||
|
type OmniRouteLoggerSink = {
|
||||||
|
error?: (message: string, ...args: unknown[]) => void;
|
||||||
|
warn: (message: string, ...args: unknown[]) => void;
|
||||||
|
debug?: (message: string, ...args: unknown[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zod schema for plugin options accepted as the second element of the
|
* Zod schema for plugin options accepted as the second element of the
|
||||||
* `plugin: [name, opts]` tuple in opencode.json. Strict by design — unknown
|
* `plugin: [name, opts]` tuple in opencode.json. Strict by design — unknown
|
||||||
@@ -791,13 +802,18 @@ export async function forceSyncOmniRouteModels(args: {
|
|||||||
try {
|
try {
|
||||||
rawCombos = await combosFetcher(auth.baseURL, auth.managementReadToken, 10_000);
|
rawCombos = await combosFetcher(auth.baseURL, auth.managementReadToken, 10_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[omniroute-plugin] force sync: combos fetch failed", err);
|
logger.warn("force sync: combos fetch failed", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let rawAutoCombos: OmniRouteRawAutoCombo[] = [];
|
let rawAutoCombos: OmniRouteRawAutoCombo[] = [];
|
||||||
if (wantAutoCombos) {
|
if (wantAutoCombos) {
|
||||||
try {
|
try {
|
||||||
rawAutoCombos = await autoCombosFetcher(auth.baseURL, auth.managementReadToken, 5_000);
|
rawAutoCombos = await autoCombosFetcher(
|
||||||
|
auth.baseURL,
|
||||||
|
auth.managementReadToken,
|
||||||
|
5_000,
|
||||||
|
logger
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
/* soft-fail */
|
/* soft-fail */
|
||||||
}
|
}
|
||||||
@@ -1089,7 +1105,7 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
auth: createOmniRouteAuthHook(resolved),
|
auth: createOmniRouteAuthHook(resolved),
|
||||||
provider: createOmniRouteProviderHook(resolved, { cache: sharedCache }),
|
provider: createOmniRouteProviderHook(resolved, { cache: sharedCache, logger }),
|
||||||
config: configWithSyncCommand,
|
config: configWithSyncCommand,
|
||||||
tool: {
|
tool: {
|
||||||
omniroute_sync_models: syncTool,
|
omniroute_sync_models: syncTool,
|
||||||
@@ -1294,10 +1310,15 @@ export function mapRawModelToModelV2(
|
|||||||
// `(providerID, modelID)`. If the raw id is already provider-prefixed
|
// `(providerID, modelID)`. If the raw id is already provider-prefixed
|
||||||
// (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or
|
// (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
|
// `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave
|
||||||
// it as-is — double-prefixing breaks OC's lookup. Otherwise prefix with
|
// it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids
|
||||||
// the resolved `providerId` so a bare key like `claude-opus-4` parses as
|
// (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed:
|
||||||
// `(omniroute, claude-opus-4)` and the credentials resolve correctly.
|
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under
|
||||||
id: raw.id.includes("/") ? raw.id : `${ctx.providerId}/${raw.id}`,
|
// the plugin provider (#10345). Other bare ids still prefix with
|
||||||
|
// `providerId` so credentials resolve as `(omniroute, model)`.
|
||||||
|
id:
|
||||||
|
raw.id.includes("/") || raw.owned_by === "combo"
|
||||||
|
? raw.id
|
||||||
|
: `${ctx.providerId}/${raw.id}`,
|
||||||
/**
|
/**
|
||||||
* Display name. Falls back to raw.id when no enrichment is available;
|
* Display name. Falls back to raw.id when no enrichment is available;
|
||||||
* the caller (`createOmniRouteProviderHook`) overlays
|
* the caller (`createOmniRouteProviderHook`) overlays
|
||||||
@@ -1671,7 +1692,8 @@ export interface OmniRouteRawAutoCombo {
|
|||||||
export type OmniRouteAutoCombosFetcher = (
|
export type OmniRouteAutoCombosFetcher = (
|
||||||
baseURL: string,
|
baseURL: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
timeoutMs?: number
|
timeoutMs?: number,
|
||||||
|
logger?: OmniRouteLoggerSink
|
||||||
) => Promise<OmniRouteRawAutoCombo[]>;
|
) => Promise<OmniRouteRawAutoCombo[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1683,9 +1705,11 @@ export type OmniRouteAutoCombosFetcher = (
|
|||||||
export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = async (
|
export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = async (
|
||||||
baseURL,
|
baseURL,
|
||||||
apiKey,
|
apiKey,
|
||||||
timeoutMs = 5_000
|
timeoutMs = 5_000,
|
||||||
|
logger?: OmniRouteLoggerSink
|
||||||
) => {
|
) => {
|
||||||
if (!apiKey || !baseURL) return [];
|
if (!apiKey || !baseURL) return [];
|
||||||
|
const log = logger ?? _logger;
|
||||||
|
|
||||||
const trimmed = trimTrailingSlashes(baseURL);
|
const trimmed = trimTrailingSlashes(baseURL);
|
||||||
const root = trimmed.replace(/\/v\d+$/, "");
|
const root = trimmed.replace(/\/v\d+$/, "");
|
||||||
@@ -1704,15 +1728,11 @@ export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = asy
|
|||||||
});
|
});
|
||||||
// 404 = endpoint not deployed yet — expected during rollout
|
// 404 = endpoint not deployed yet — expected during rollout
|
||||||
if (res.status === 404) {
|
if (res.status === 404) {
|
||||||
console.warn(
|
log.warn(`/api/combos/auto not available (404) — auto combos disabled`);
|
||||||
`[omniroute-plugin] /api/combos/auto not available (404) — auto combos disabled`
|
|
||||||
);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.warn(
|
log.warn(`/api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled`);
|
||||||
`[omniroute-plugin] /api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled`
|
|
||||||
);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const body = (await res.json()) as unknown;
|
const body = (await res.json()) as unknown;
|
||||||
@@ -1730,8 +1750,8 @@ export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = asy
|
|||||||
return out;
|
return out;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Network error, timeout, abort — all non-fatal
|
// Network error, timeout, abort — all non-fatal
|
||||||
console.warn(
|
log.warn(
|
||||||
`[omniroute-plugin] /api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled`
|
`/api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled`
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2930,10 +2950,7 @@ export function passesModelAllowlist(
|
|||||||
* filter is set, all combos pass. Combos with zero resolvable members pass
|
* filter is set, all combos pass. Combos with zero resolvable members pass
|
||||||
* (mirrors `isUsableCombo` semantics).
|
* (mirrors `isUsableCombo` semantics).
|
||||||
*/
|
*/
|
||||||
export function passesComboAllowlist(
|
export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelListFilter): boolean {
|
||||||
combo: OmniRouteRawCombo,
|
|
||||||
visible?: ModelListFilter
|
|
||||||
): boolean {
|
|
||||||
if (!visible) return true;
|
if (!visible) return true;
|
||||||
const steps = Array.isArray(combo.models) ? combo.models : [];
|
const steps = Array.isArray(combo.models) ? combo.models : [];
|
||||||
if (steps.length === 0) return true;
|
if (steps.length === 0) return true;
|
||||||
@@ -3125,9 +3142,15 @@ export function createOmniRouteProviderHook(
|
|||||||
providersFetcher?: OmniRouteProvidersFetcher;
|
providersFetcher?: OmniRouteProvidersFetcher;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
cache?: OmniRouteFetchCache;
|
cache?: OmniRouteFetchCache;
|
||||||
|
logger?: _Logger;
|
||||||
} = {}
|
} = {}
|
||||||
): ProviderHook {
|
): ProviderHook {
|
||||||
const resolved = resolveOmniRoutePluginOptions(opts);
|
const resolved = resolveOmniRoutePluginOptions(opts);
|
||||||
|
const logger =
|
||||||
|
deps.logger ??
|
||||||
|
createLogger(
|
||||||
|
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
|
||||||
|
);
|
||||||
const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher;
|
const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher;
|
||||||
// T-05: combo discovery merges `/api/combos` entries into the same map as
|
// T-05: combo discovery merges `/api/combos` entries into the same map as
|
||||||
// `/v1/models`. Default fetcher is declared further down the file; the
|
// `/v1/models`. Default fetcher is declared further down the file; the
|
||||||
@@ -3201,8 +3224,8 @@ export function createOmniRouteProviderHook(
|
|||||||
: undefined) ??
|
: undefined) ??
|
||||||
"";
|
"";
|
||||||
if (!baseURL) {
|
if (!baseURL) {
|
||||||
console.warn(
|
logger.error(
|
||||||
`[omniroute-plugin] provider.models(${resolved.providerId}): ` +
|
`provider.models(${resolved.providerId}): ` +
|
||||||
`no baseURL resolvable — checked plugin opts, auth.json, and provider config. ` +
|
`no baseURL resolvable — checked plugin opts, auth.json, and provider config. ` +
|
||||||
`Set baseURL in opencode.json plugin options or run \`opencode connect ${resolved.providerId}\` with a baseURL.`
|
`Set baseURL in opencode.json plugin options or run \`opencode connect ${resolved.providerId}\` with a baseURL.`
|
||||||
);
|
);
|
||||||
@@ -3233,8 +3256,8 @@ export function createOmniRouteProviderHook(
|
|||||||
rawModels = await fetcher(baseURL, apiKey, 10_000);
|
rawModels = await fetcher(baseURL, apiKey, 10_000);
|
||||||
|
|
||||||
// T-05: combos fetch is best-effort, gated by features.combos.
|
// T-05: combos fetch is best-effort, gated by features.combos.
|
||||||
// Soft-fail on any error: emit a console.warn and fall back to a
|
// Soft-fail on any error: emit a warn-level diagnostic and fall back
|
||||||
// models-only catalog. Rationale: /api/combos requires a
|
// to a models-only catalog. Rationale: /api/combos requires a
|
||||||
// management-scoped key and OmniRoute may not have any combos
|
// management-scoped key and OmniRoute may not have any combos
|
||||||
// provisioned. Hard-failing when combos are optional would
|
// provisioned. Hard-failing when combos are optional would
|
||||||
// silently hide the whole provider from OC's picker.
|
// silently hide the whole provider from OC's picker.
|
||||||
@@ -3243,10 +3266,7 @@ export function createOmniRouteProviderHook(
|
|||||||
try {
|
try {
|
||||||
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
|
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
logger.warn("combos fetch failed, falling back to models-only catalog", err);
|
||||||
"[omniroute-plugin] combos fetch failed, falling back to models-only catalog",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3256,7 +3276,7 @@ export function createOmniRouteProviderHook(
|
|||||||
rawAutoCombos = [];
|
rawAutoCombos = [];
|
||||||
if (wantAutoCombos) {
|
if (wantAutoCombos) {
|
||||||
try {
|
try {
|
||||||
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
|
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000, logger);
|
||||||
} catch {
|
} catch {
|
||||||
// Already handled inside the default fetcher — this catch
|
// Already handled inside the default fetcher — this catch
|
||||||
// is belt-and-suspenders for injected stubs.
|
// is belt-and-suspenders for injected stubs.
|
||||||
@@ -3270,10 +3290,7 @@ export function createOmniRouteProviderHook(
|
|||||||
try {
|
try {
|
||||||
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
|
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
logger.warn("enrichment fetch failed, falling back to raw ids", err);
|
||||||
"[omniroute-plugin] enrichment fetch failed, falling back to raw ids",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3288,7 +3305,7 @@ export function createOmniRouteProviderHook(
|
|||||||
10_000
|
10_000
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[omniroute-plugin] compression-metadata fetch failed", err);
|
logger.warn("compression-metadata fetch failed", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3302,8 +3319,8 @@ export function createOmniRouteProviderHook(
|
|||||||
try {
|
try {
|
||||||
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
|
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
logger.warn(
|
||||||
"[omniroute-plugin] /api/providers fetch failed; usableOnly filter disabled for this refresh",
|
"/api/providers fetch failed; usableOnly filter disabled for this refresh",
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3322,8 +3339,9 @@ export function createOmniRouteProviderHook(
|
|||||||
// Debug breadcrumb: surface fetch result so operators can confirm
|
// Debug breadcrumb: surface fetch result so operators can confirm
|
||||||
// the dynamic pipeline fired and how much catalog OmniRoute returned.
|
// the dynamic pipeline fired and how much catalog OmniRoute returned.
|
||||||
// Emitted once per cache miss (TTL refresh) — quiet on cache hits.
|
// Emitted once per cache miss (TTL refresh) — quiet on cache hits.
|
||||||
console.warn(
|
// Info-level: hidden at the default `warn` level (see #8982).
|
||||||
`[omniroute-plugin] catalog refreshed for providerId=${resolved.providerId} baseURL=${baseURL}: ` +
|
logger.info(
|
||||||
|
`catalog refreshed for providerId=${resolved.providerId} baseURL=${baseURL}: ` +
|
||||||
`${rawModels.length} models + ${rawCombos.length} combos + ` +
|
`${rawModels.length} models + ${rawCombos.length} combos + ` +
|
||||||
`${rawEnrichment.size} enrichment entries + ` +
|
`${rawEnrichment.size} enrichment entries + ` +
|
||||||
`${rawCompressionCombos.length} compression combos + ` +
|
`${rawCompressionCombos.length} compression combos + ` +
|
||||||
@@ -3603,9 +3621,7 @@ export function createOmniRouteProviderHook(
|
|||||||
const dedupeKey = `${cacheKey}::${comboKey}`;
|
const dedupeKey = `${cacheKey}::${comboKey}`;
|
||||||
if (!collisionWarned.has(dedupeKey)) {
|
if (!collisionWarned.has(dedupeKey)) {
|
||||||
collisionWarned.add(dedupeKey);
|
collisionWarned.add(dedupeKey);
|
||||||
console.warn(
|
logger.warn(`combo key "${comboKey}" collides with a model id; combo wins.`);
|
||||||
`[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3623,8 +3639,8 @@ export function createOmniRouteProviderHook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pending.length > 0) {
|
if (pending.length > 0) {
|
||||||
console.warn(
|
logger.warn(
|
||||||
`[omniroute-plugin] ${pending.length} combo(s) could not resolve all nested combo-refs after ${MAX_COMBO_PASSES} passes; they will advertise context=0 to avoid over-claiming.`
|
`${pending.length} combo(s) could not resolve all nested combo-refs after ${MAX_COMBO_PASSES} passes; they will advertise context=0 to avoid over-claiming.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4268,8 +4284,10 @@ export function buildStaticProviderEntry(
|
|||||||
enrichment?: OmniRouteEnrichmentMap,
|
enrichment?: OmniRouteEnrichmentMap,
|
||||||
compressionCombos?: OmniRouteCompressionCombo[],
|
compressionCombos?: OmniRouteCompressionCombo[],
|
||||||
connections?: OmniRouteProviderConnection[],
|
connections?: OmniRouteProviderConnection[],
|
||||||
rawAutoCombos?: OmniRouteRawAutoCombo[]
|
rawAutoCombos?: OmniRouteRawAutoCombo[],
|
||||||
|
logger?: OmniRouteLoggerSink
|
||||||
): OmniRouteStaticProviderEntry {
|
): OmniRouteStaticProviderEntry {
|
||||||
|
const log = logger ?? _logger;
|
||||||
const models: Record<string, OmniRouteStaticModelEntry> = {};
|
const models: Record<string, OmniRouteStaticModelEntry> = {};
|
||||||
const rawModelKeys = new Set<string>();
|
const rawModelKeys = new Set<string>();
|
||||||
|
|
||||||
@@ -4647,8 +4665,8 @@ export function buildStaticProviderEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pendingStatic.length > 0) {
|
if (pendingStatic.length > 0) {
|
||||||
console.warn(
|
log.warn(
|
||||||
`[omniroute-plugin] ${pendingStatic.length} combo(s) in the static catalog could not resolve all nested combo-refs after ${MAX_STATIC_COMBO_PASSES} passes; they will be omitted.`
|
`${pendingStatic.length} combo(s) in the static catalog could not resolve all nested combo-refs after ${MAX_STATIC_COMBO_PASSES} passes; they will be omitted.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4669,9 +4687,7 @@ export function buildStaticProviderEntry(
|
|||||||
const isExpectedRawTwin = autoCombo.id === key && rawModelKeys.has(key);
|
const isExpectedRawTwin = autoCombo.id === key && rawModelKeys.has(key);
|
||||||
if (!isExpectedRawTwin && !reportedCollisions.has(key)) {
|
if (!isExpectedRawTwin && !reportedCollisions.has(key)) {
|
||||||
reportedCollisions.add(key);
|
reportedCollisions.add(key);
|
||||||
console.warn(
|
log.warn(`auto combo key "${key}" collides with an existing model; auto combo wins.`);
|
||||||
`[omniroute-plugin] auto combo key "${key}" collides with an existing model; auto combo wins.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
models[key] = entry;
|
models[key] = entry;
|
||||||
@@ -5342,7 +5358,8 @@ export function createOmniRouteConfigHook(
|
|||||||
warmSnapshot = snapshotResult;
|
warmSnapshot = snapshotResult;
|
||||||
// Log snapshot age (accept any age — instant beats empty).
|
// Log snapshot age (accept any age — instant beats empty).
|
||||||
const age = (snapshotResult as { writtenAt?: number }).writtenAt;
|
const age = (snapshotResult as { writtenAt?: number }).writtenAt;
|
||||||
const ageLabel = typeof age === "number" ? `${Math.round((Date.now() - age) / 3_600_000)}h` : "unknown";
|
const ageLabel =
|
||||||
|
typeof age === "number" ? `${Math.round((Date.now() - age) / 3_600_000)}h` : "unknown";
|
||||||
logAt(
|
logAt(
|
||||||
"warn",
|
"warn",
|
||||||
`config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
|
`config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
|
||||||
@@ -5394,7 +5411,12 @@ export function createOmniRouteConfigHook(
|
|||||||
const doAutoCombos = async (): Promise<void> => {
|
const doAutoCombos = async (): Promise<void> => {
|
||||||
if (!wantAutoCombos) return;
|
if (!wantAutoCombos) return;
|
||||||
try {
|
try {
|
||||||
localRawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
|
localRawAutoCombos = await autoCombosFetcher(
|
||||||
|
baseURL,
|
||||||
|
managementReadToken,
|
||||||
|
5_000,
|
||||||
|
logger
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Already handled inside the default fetcher
|
// Already handled inside the default fetcher
|
||||||
}
|
}
|
||||||
@@ -5415,7 +5437,11 @@ export function createOmniRouteConfigHook(
|
|||||||
const doCompression = async (): Promise<void> => {
|
const doCompression = async (): Promise<void> => {
|
||||||
if (!wantCompressionMeta) return;
|
if (!wantCompressionMeta) return;
|
||||||
try {
|
try {
|
||||||
localRawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
|
localRawCompressionCombos = await compressionMetaFetcher(
|
||||||
|
baseURL,
|
||||||
|
managementReadToken,
|
||||||
|
10_000
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logAt(
|
logAt(
|
||||||
"error",
|
"error",
|
||||||
@@ -5528,7 +5554,8 @@ export function createOmniRouteConfigHook(
|
|||||||
localRawEnrichment,
|
localRawEnrichment,
|
||||||
localRawCompressionCombos,
|
localRawCompressionCombos,
|
||||||
localRawConnections,
|
localRawConnections,
|
||||||
localRawAutoCombos
|
localRawAutoCombos,
|
||||||
|
logger
|
||||||
);
|
);
|
||||||
const inputWithProvider2 = input as { provider?: Record<string, unknown> };
|
const inputWithProvider2 = input as { provider?: Record<string, unknown> };
|
||||||
if (inputWithProvider2.provider) {
|
if (inputWithProvider2.provider) {
|
||||||
@@ -5618,7 +5645,8 @@ export function createOmniRouteConfigHook(
|
|||||||
rawEnrichment,
|
rawEnrichment,
|
||||||
rawCompressionCombos,
|
rawCompressionCombos,
|
||||||
rawConnections,
|
rawConnections,
|
||||||
rawAutoCombos
|
rawAutoCombos,
|
||||||
|
logger
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mutate the input.provider map. The Config type declares
|
// Mutate the input.provider map. The Config type declares
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { mapRawModelToModelV2 } from "../src/index.ts";
|
||||||
|
|
||||||
|
test("mapRawModelToModelV2: bare combo ids stay unprefixed (#10345)", () => {
|
||||||
|
const combo = mapRawModelToModelV2(
|
||||||
|
{
|
||||||
|
id: "gpt-5.6-sol",
|
||||||
|
owned_by: "combo",
|
||||||
|
context_length: 272000,
|
||||||
|
max_output_tokens: 8192,
|
||||||
|
},
|
||||||
|
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||||
|
);
|
||||||
|
assert.equal(combo.id, "gpt-5.6-sol");
|
||||||
|
assert.equal(combo.providerID, "omniroute");
|
||||||
|
|
||||||
|
const slashed = mapRawModelToModelV2(
|
||||||
|
{
|
||||||
|
id: "cx/gpt-5.6-sol",
|
||||||
|
owned_by: "combo",
|
||||||
|
context_length: 272000,
|
||||||
|
},
|
||||||
|
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||||
|
);
|
||||||
|
assert.equal(slashed.id, "cx/gpt-5.6-sol");
|
||||||
|
|
||||||
|
const ordinary = mapRawModelToModelV2(
|
||||||
|
{ id: "claude-primary", context_length: 200000 },
|
||||||
|
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||||
|
);
|
||||||
|
assert.equal(ordinary.id, "omniroute/claude-primary");
|
||||||
|
});
|
||||||
@@ -5,8 +5,14 @@ import { join } from "node:path";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import type { Config } from "@opencode-ai/plugin";
|
import type { Config } from "@opencode-ai/plugin";
|
||||||
|
|
||||||
import { createOmniRouteConfigHook, OmniRoutePlugin } from "../src/index.js";
|
import {
|
||||||
import { getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
|
createOmniRouteConfigHook,
|
||||||
|
createOmniRouteProviderHook,
|
||||||
|
defaultOmniRouteAutoCombosFetcher,
|
||||||
|
OmniRoutePlugin,
|
||||||
|
type OmniRouteRawModelEntry,
|
||||||
|
} from "../src/index.js";
|
||||||
|
import { createLogger, getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
|
||||||
|
|
||||||
type ConsoleMethod = "error" | "info" | "log" | "warn";
|
type ConsoleMethod = "error" | "info" | "log" | "warn";
|
||||||
type ConsoleEntries = Record<ConsoleMethod, unknown[][]>;
|
type ConsoleEntries = Record<ConsoleMethod, unknown[][]>;
|
||||||
@@ -216,3 +222,105 @@ test("logger error output remains visible at error level", async () => {
|
|||||||
setLogLevel(previousLevel);
|
setLogLevel(previousLevel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const MINIMAL_MODELS: OmniRouteRawModelEntry[] = [
|
||||||
|
{
|
||||||
|
id: "claude-primary",
|
||||||
|
object: "model",
|
||||||
|
owned_by: "combo",
|
||||||
|
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true },
|
||||||
|
context_length: 200000,
|
||||||
|
max_output_tokens: 64000,
|
||||||
|
input_modalities: ["text", "image"],
|
||||||
|
output_modalities: ["text"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function providerHookWithLevel(level: LogLevel, baseURL?: string) {
|
||||||
|
return createOmniRouteProviderHook(
|
||||||
|
{
|
||||||
|
baseURL,
|
||||||
|
features: { autoCombos: false, enrichment: false, logLevel: level },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fetcher: async () => MINIMAL_MODELS,
|
||||||
|
combosFetcher: async () => {
|
||||||
|
throw new Error("combos boom");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("logLevel error suppresses provider.models() fallback warnings and the catalog-refresh breadcrumb", async () => {
|
||||||
|
const hook = providerHookWithLevel("error", "https://or.example.com/v1");
|
||||||
|
const lines = rendered(
|
||||||
|
await captureConsole(async () => {
|
||||||
|
await hook.models!({} as never, { auth: { type: "api", key: "sk-x" } as never });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(lines.filter((line) => line.includes("combos fetch failed")).length, 0);
|
||||||
|
assert.equal(lines.filter((line) => line.includes("catalog refreshed")).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logLevel debug preserves the provider.models() catalog-refresh breadcrumb", async () => {
|
||||||
|
const hook = providerHookWithLevel("debug", "https://or.example.com/v1");
|
||||||
|
const lines = rendered(
|
||||||
|
await captureConsole(async () => {
|
||||||
|
await hook.models!({} as never, { auth: { type: "api", key: "sk-x" } as never });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
lines.some((line) => line.includes("catalog refreshed")),
|
||||||
|
"catalog-refresh breadcrumb emitted at debug level"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no baseURL resolvable stays visible at error level", async () => {
|
||||||
|
const hook = providerHookWithLevel("error");
|
||||||
|
const lines = rendered(
|
||||||
|
await captureConsole(async () => {
|
||||||
|
await hook.models!({} as never, { auth: { type: "api", key: "sk-x" } as never });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
lines.some((line) => line.includes("no baseURL resolvable")),
|
||||||
|
"genuine misconfiguration error remains visible at error level"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("default auto-combos fetcher 404 warning respects the threaded logger level", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
(globalThis as { fetch: unknown }).fetch = (async () => ({
|
||||||
|
status: 404,
|
||||||
|
ok: false,
|
||||||
|
})) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const silent = await captureConsole(async () => {
|
||||||
|
await defaultOmniRouteAutoCombosFetcher(
|
||||||
|
"https://or.example.com/v1",
|
||||||
|
"sk-x",
|
||||||
|
5_000,
|
||||||
|
createLogger("error")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert.equal(rendered(silent).length, 0, "404 warning suppressed at error level");
|
||||||
|
|
||||||
|
const loud = await captureConsole(async () => {
|
||||||
|
await defaultOmniRouteAutoCombosFetcher(
|
||||||
|
"https://or.example.com/v1",
|
||||||
|
"sk-x",
|
||||||
|
5_000,
|
||||||
|
createLogger("warn")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
rendered(loud).some((line) => line.includes("/api/combos/auto not available")),
|
||||||
|
"404 warning emitted at warn level"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
|
|||||||
|
|
||||||
## Project at a Glance
|
## Project at a Glance
|
||||||
|
|
||||||
**OmniRoute** — unified AI proxy/router. One endpoint, 342 LLM providers, auto-fallback.
|
**OmniRoute** — unified AI proxy/router. One endpoint, 348 LLM providers, auto-fallback.
|
||||||
|
|
||||||
| Layer | Location | Purpose |
|
| Layer | Location | Purpose |
|
||||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -56,9 +56,9 @@ Repository map and Reference Documentation sections below.
|
|||||||
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
||||||
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
||||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||||
| Database | `src/lib/db/` | SQLite domain modules (154 migrations) |
|
| Database | `src/lib/db/` | SQLite domain modules (157 migrations) |
|
||||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||||
| MCP Server | `open-sse/mcp-server/` | 109 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
|
| MCP Server | `open-sse/mcp-server/` | 110 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
|
||||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||||
| Skills | `src/lib/skills/` | Extensible skill framework |
|
| Skills | `src/lib/skills/` | Extensible skill framework |
|
||||||
| Memory | `src/lib/memory/` | Persistent conversational memory |
|
| Memory | `src/lib/memory/` | Persistent conversational memory |
|
||||||
|
|||||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### ✨ New Features
|
||||||
|
|
||||||
|
- **feat(sse): STRICT_ZERO_COST** — opt-in, off-by-default `freeAccessPolicy: "strict"` setting
|
||||||
|
that hard-verifies every auto-combo candidate against live quota state and per-connection
|
||||||
|
economic safety before it can be dispatched, going beyond `hidePaidModels`'s static catalog
|
||||||
|
check. Adds curated `hardStopGuaranteed` metadata to `FREE_MODEL_BUDGETS`, a short-TTL quota
|
||||||
|
cache reusing `getUsageForProvider()`, and a connection-safety guarantee: a candidate backed
|
||||||
|
by multiple accounts has its `allowedConnectionIds` narrowed to exactly the connections
|
||||||
|
independently verified `SAFE`, so dispatch can never use an unverified account. An
|
||||||
|
`excludeTosAvoid` guard (default `false`) is available separately for contractual risk. See
|
||||||
|
`docs/routing/STRICT_ZERO_COST.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [3.8.50] — TBD
|
## [3.8.50] — TBD
|
||||||
@@ -9,6 +21,7 @@
|
|||||||
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._
|
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
- **feat(search):** first-class X Search provider (`x-search`) on `POST /v1/search` and MCP `omniroute_x_search` using SuperGrok / xAI server-side `x_search`. Explicit provider or `search_type: "x"` only — never auto-selected for web. Reuses `xai-oauth` / `xao` / `xai` credentials. Not the X Developer Platform MCP. ([#10985](https://github.com/diegosouzapw/OmniRoute/issues/10985))
|
||||||
- **feat(core):** add Layer A capability filter at router (#5696)
|
- **feat(core):** add Layer A capability filter at router (#5696)
|
||||||
- **feat(providers):** add DeepAI as paid API-key image provider ([#6671](https://github.com/diegosouzapw/OmniRoute/issues/6671))
|
- **feat(providers):** add DeepAI as paid API-key image provider ([#6671](https://github.com/diegosouzapw/OmniRoute/issues/6671))
|
||||||
- **feat(providers):** add Naga.ac and ChatAnywhere aggregator gateway providers (#6674 — thanks @chirag127)
|
- **feat(providers):** add Naga.ac and ChatAnywhere aggregator gateway providers (#6674 — thanks @chirag127)
|
||||||
@@ -169,6 +182,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
|
|||||||
|
|
||||||
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
|
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
|
||||||
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
|
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
|
||||||
|
- **cli**: route provider test commands through configured connection test endpoints (#10570)
|
||||||
- **executors**: fix internal timeout misclassified as client disconnect (499) for 7 niche executors — pass TimeoutError reason to controller.abort() (#8197 side-finding)
|
- **executors**: fix internal timeout misclassified as client disconnect (499) for 7 niche executors — pass TimeoutError reason to controller.abort() (#8197 side-finding)
|
||||||
- test(combo): guard auto/best-free never leaks the combo name as a model (#7754)
|
- test(combo): guard auto/best-free never leaks the combo name as a model (#7754)
|
||||||
- fix(vision-bridge): describe-model no longer returns unreachable "openai/gpt-4o-mini" when every vision-capable provider is unreachable on the instance — returns null instead and surfaces a clear error (#8430)
|
- fix(vision-bridge): describe-model no longer returns unreachable "openai/gpt-4o-mini" when every vision-capable provider is unreachable on the instance — returns null instead and surfaces a clear error (#8430)
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ COPY . ./
|
|||||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
|
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
|
||||||
mkdir -p /app/data \
|
mkdir -p /app/data \
|
||||||
&& npm run build \
|
&& npm run build \
|
||||||
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
|
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
|
||||||
|
|
||||||
# ── Runner base ────────────────────────────────────────────────────────────
|
# ── Runner base ────────────────────────────────────────────────────────────
|
||||||
FROM base AS runner-base
|
FROM base AS runner-base
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
# 🚀 OmniRoute — The Free AI Gateway
|
# 🚀 OmniRoute — The Free AI Gateway
|
||||||
|
|
||||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 342 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 342 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
|
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 348 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 348 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td align="right"><b>⚙️ Features</b></td>
|
<td align="right"><b>⚙️ Features</b></td>
|
||||||
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
|
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
|
||||||
<td align="center"><a href="#-342-ai-providers--90-free">🌐 Providers</a></td>
|
<td align="center"><a href="#-348-ai-providers--90-free">🌐 Providers</a></td>
|
||||||
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI & MCP</a></td>
|
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI & MCP</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 342 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 342 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 348 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 348 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 57 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals — 25,000+ tests)."/>
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step:
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 342 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs."/>
|
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 348 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs."/>
|
||||||
|
|
||||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||||
|
|
||||||
@@ -559,7 +559,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
|||||||
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
|
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
|
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||||
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
|
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
|
||||||
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **342-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **348-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||||
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||||
- **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
|
- **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md)
|
||||||
|
|
||||||
@@ -642,11 +642,11 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
## 🌐 342 AI Providers — 90+ Free
|
## 🌐 348 AI Providers — 90+ Free
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> The most complete catalog of any open-source router: **342 providers**, **90+ with a free tier**, **56 free forever**.
|
> The most complete catalog of any open-source router: **348 providers**, **90+ with a free tier**, **57 free forever**.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -821,7 +821,7 @@ Expose OmniRoute over **MCP**, **A2A**, a **REST API**, **webhooks** or a **remo
|
|||||||
<table>
|
<table>
|
||||||
<tr><th align="left">Interface</th><th align="left">Endpoint / command</th><th align="left">Use it for</th></tr>
|
<tr><th align="left">Interface</th><th align="left">Endpoint / command</th><th align="left">Use it for</th></tr>
|
||||||
<tr><td align="left" nowrap>🧰 <b>MCP (stdio)</b></td><td align="left" nowrap><code>omniroute --mcp</code></td><td align="left">Plug into Claude Desktop, Cursor, any MCP client</td></tr>
|
<tr><td align="left" nowrap>🧰 <b>MCP (stdio)</b></td><td align="left" nowrap><code>omniroute --mcp</code></td><td align="left">Plug into Claude Desktop, Cursor, any MCP client</td></tr>
|
||||||
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>109 tools</b>, 33 scopes, full audit trail</td></tr>
|
<tr><td align="left" nowrap>🌊 <b>MCP (HTTP)</b></td><td align="left" nowrap><code>/api/mcp/stream</code></td><td align="left">Remote MCP — <b>110 tools</b>, 33 scopes, full audit trail</td></tr>
|
||||||
<tr><td align="left" nowrap>📡 <b>MCP (SSE)</b></td><td align="left" nowrap><code>/api/mcp/sse</code></td><td align="left">Streaming MCP transport</td></tr>
|
<tr><td align="left" nowrap>📡 <b>MCP (SSE)</b></td><td align="left" nowrap><code>/api/mcp/sse</code></td><td align="left">Streaming MCP transport</td></tr>
|
||||||
<tr><td align="left" nowrap>🤝 <b>A2A</b></td><td align="left" nowrap><code>/.well-known/agent.json</code></td><td align="left">Agent-to-agent, <b>JSON-RPC 2.0</b> + SSE, 6 skills</td></tr>
|
<tr><td align="left" nowrap>🤝 <b>A2A</b></td><td align="left" nowrap><code>/.well-known/agent.json</code></td><td align="left">Agent-to-agent, <b>JSON-RPC 2.0</b> + SSE, 6 skills</td></tr>
|
||||||
<tr><td align="left" nowrap>🌐 <b>REST API</b></td><td align="left" nowrap><code>/v1/*</code></td><td align="left">OpenAI-compatible — chat, embeddings, images, audio, OCR</td></tr>
|
<tr><td align="left" nowrap>🌐 <b>REST API</b></td><td align="left" nowrap><code>/v1/*</code></td><td align="left">OpenAI-compatible — chat, embeddings, images, audio, OCR</td></tr>
|
||||||
@@ -988,6 +988,21 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
|
|||||||
-p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
|
-p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`:latest` follows the highest **published** stable SemVer. It does not track git `main`. Pin `:X.Y.Z` for GitOps. See [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).The image pins **`OMNIROUTE_MEMORY_MB=1024`**. That is enough for the dashboard and a light chat. **Coding agents** (`POST /v1/responses` from Claude Code, Codex, Grok, …) need a much larger V8 heap or the process `FATAL ERROR`s at ~12 GiB under two overlapping long contexts. Size the container above the heap (native buffers sit outside V8):
|
||||||
|
|
||||||
|
| Workload | Heap (`-e OMNIROUTE_MEMORY_MB`) | Container (`--memory`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dashboard / light chat | `1024` (image default) | ≥2 g |
|
||||||
|
| One coding agent | `8192` | ≥10 g |
|
||||||
|
| Two concurrent long `/v1/responses` | `10240`–`12288` | ≥12–16 g |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \
|
||||||
|
-e OMNIROUTE_MEMORY_MB=8192 --memory=10g \
|
||||||
|
-p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-ram-for-coding-agents).
|
||||||
> **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and
|
> **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and
|
||||||
> `diegosouzapw/omniroute:next-web` follow the current default `release/v*`
|
> `diegosouzapw/omniroute:next-web` follow the current default `release/v*`
|
||||||
> branch. These mutable tags are intended only for testing unreleased fixes and
|
> branch. These mutable tags are intended only for testing unreleased fixes and
|
||||||
@@ -1172,7 +1187,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c
|
|||||||
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>>=22.22.2 <23 || >=24.0.0 <27</code></td></tr>
|
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>>=22.22.2 <23 || >=24.0.0 <27</code></td></tr>
|
||||||
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
|
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
|
||||||
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
|
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
|
||||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 154 migrations</td></tr>
|
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 157 migrations</td></tr>
|
||||||
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
|
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
|
||||||
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
|
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
|
||||||
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
|
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
|
||||||
@@ -1495,7 +1510,7 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router]
|
|||||||
<table>
|
<table>
|
||||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||||
<tr><td nowrap><b><a href="https://github.com/toon-format/toon">TOON</a></b></td><td align="center">24.9k</td><td>Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage.</td></tr>
|
<tr><td nowrap><b><a href="https://github.com/toon-format/toon">TOON</a></b></td><td align="center">24.9k</td><td>Token-Oriented Object Notation — its columnar, header-plus-rows model shaped our tabular compaction stage.</td></tr>
|
||||||
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf">GCF – Graph Compact Format</a></b></td><td align="center">22</td><td>First inspired our tabular compaction stage; now its zero-dependency, lossless generic-profile encoder is <b>vendored directly</b> as the Headroom codec (MIT, SPDX-marked), current with GCF spec v3.2.</td></tr>
|
<tr><td nowrap><b><a href="https://github.com/blackwell-systems/gcf">GCF – Graph Compact Format</a></b></td><td align="center">22</td><td>First inspired our tabular compaction stage; now its zero-dependency, lossless generic-profile encoder is <b>vendored directly</b> as the Headroom codec (MIT, SPDX-marked), with later numeric-domain and count-mismatch correctness fixes.</td></tr>
|
||||||
<tr><td nowrap><b><a href="https://github.com/ooples/token-optimizer-mcp">token-optimizer-mcp</a></b></td><td align="center">444</td><td>Brotli/SQLite cache + per-session context-delta — inspired our <code>session-dedup</code> engine.</td></tr>
|
<tr><td nowrap><b><a href="https://github.com/ooples/token-optimizer-mcp">token-optimizer-mcp</a></b></td><td align="center">444</td><td>Brotli/SQLite cache + per-session context-delta — inspired our <code>session-dedup</code> engine.</td></tr>
|
||||||
<tr><td nowrap><b><a href="https://github.com/Mibayy/token-savior">token-savior</a></b></td><td align="center">1.1k</td><td>Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction.</td></tr>
|
<tr><td nowrap><b><a href="https://github.com/Mibayy/token-savior">token-savior</a></b></td><td align="center">1.1k</td><td>Bash-output compaction + MCP profiles — inspired our compression bail-out discipline and MCP tool-manifest reduction.</td></tr>
|
||||||
<tr><td nowrap><b><a href="https://github.com/ppgranger/token-saver">token-saver</a></b></td><td align="center">117</td><td>Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip.</td></tr>
|
<tr><td nowrap><b><a href="https://github.com/ppgranger/token-saver">token-saver</a></b></td><td align="center">117</td><td>Content-aware, per-file-type output compression with failure-aware bail-out — validated our per-type dispatch and minimum-gain skip.</td></tr>
|
||||||
|
|||||||
@@ -30,20 +30,60 @@ export function register_combos(parent) {
|
|||||||
const data = res.ok ? await res.json() : await res.text();
|
const data = res.ok ? await res.json() : await res.text();
|
||||||
emit(data, gOpts);
|
emit(data, gOpts);
|
||||||
});
|
});
|
||||||
tag.command("patch-api-combos-id-")
|
tag.command("get-api-combos-id-")
|
||||||
.description("Update combo")
|
.description("Get combo by ID")
|
||||||
|
.requiredOption("--id <id>", "")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const gOpts = cmd.optsWithGlobals();
|
const gOpts = cmd.optsWithGlobals();
|
||||||
let url = "/api/combos/{id}";
|
let url = "/api/combos/{id}";
|
||||||
const res = await apiFetch(url, { method: "PATCH", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
|
||||||
|
const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||||
|
const data = res.ok ? await res.json() : await res.text();
|
||||||
|
emit(data, gOpts);
|
||||||
|
});
|
||||||
|
tag.command("put-api-combos-id-")
|
||||||
|
.description("Update combo")
|
||||||
|
.requiredOption("--id <id>", "")
|
||||||
|
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||||
|
.action(async (opts, cmd) => {
|
||||||
|
const gOpts = cmd.optsWithGlobals();
|
||||||
|
let url = "/api/combos/{id}";
|
||||||
|
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
|
||||||
|
let body;
|
||||||
|
if (opts.body) {
|
||||||
|
body = opts.body.startsWith("@")
|
||||||
|
? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
|
||||||
|
: JSON.parse(opts.body);
|
||||||
|
}
|
||||||
|
const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||||
|
const data = res.ok ? await res.json() : await res.text();
|
||||||
|
emit(data, gOpts);
|
||||||
|
});
|
||||||
|
tag.command("patch-api-combos-id-")
|
||||||
|
.description("Update combo")
|
||||||
|
.requiredOption("--id <id>", "")
|
||||||
|
.option("--body <jsonOrPath>", "JSON body or @path/to/file.json")
|
||||||
|
.action(async (opts, cmd) => {
|
||||||
|
const gOpts = cmd.optsWithGlobals();
|
||||||
|
let url = "/api/combos/{id}";
|
||||||
|
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
|
||||||
|
let body;
|
||||||
|
if (opts.body) {
|
||||||
|
body = opts.body.startsWith("@")
|
||||||
|
? JSON.parse(readFileSync(opts.body.slice(1), "utf8"))
|
||||||
|
: JSON.parse(opts.body);
|
||||||
|
}
|
||||||
|
const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||||
const data = res.ok ? await res.json() : await res.text();
|
const data = res.ok ? await res.json() : await res.text();
|
||||||
emit(data, gOpts);
|
emit(data, gOpts);
|
||||||
});
|
});
|
||||||
tag.command("delete-api-combos-id-")
|
tag.command("delete-api-combos-id-")
|
||||||
.description("Delete combo")
|
.description("Delete combo")
|
||||||
|
.requiredOption("--id <id>", "")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const gOpts = cmd.optsWithGlobals();
|
const gOpts = cmd.optsWithGlobals();
|
||||||
let url = "/api/combos/{id}";
|
let url = "/api/combos/{id}";
|
||||||
|
url = url.replace("{id}", encodeURIComponent(opts.id ?? ""));
|
||||||
const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
|
||||||
const data = res.ok ? await res.json() : await res.text();
|
const data = res.ok ? await res.json() : await res.text();
|
||||||
emit(data, gOpts);
|
emit(data, gOpts);
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ function resolveUrl(path, opts) {
|
|||||||
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
|
return `${getBaseUrl(opts)}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The machine-derived token is valid only for the local loopback server. */
|
||||||
|
export function isLoopbackUrl(value) {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||||
|
if (hostname === "localhost" || hostname === "::1") return true;
|
||||||
|
if (/^127(?:\.[0-9]{1,3}){3}$/.test(hostname)) return true;
|
||||||
|
if (/^::ffff:(?:127\.|7f[0-9a-f]{2}:)/i.test(hostname)) return true;
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildHeaders(opts) {
|
export async function buildHeaders(opts) {
|
||||||
const headers = new Headers(opts.headers || {});
|
const headers = new Headers(opts.headers || {});
|
||||||
if (!headers.has("accept")) headers.set("accept", "application/json");
|
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||||
@@ -87,10 +100,17 @@ export async function buildHeaders(opts) {
|
|||||||
if (auth && !headers.has("authorization")) {
|
if (auth && !headers.has("authorization")) {
|
||||||
headers.set("authorization", `Bearer ${auth}`);
|
headers.set("authorization", `Bearer ${auth}`);
|
||||||
}
|
}
|
||||||
// Inject machine-id derived CLI token; env var override for testing.
|
// Inject the machine-derived credential only for an explicit local loopback
|
||||||
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
|
// destination. Remote contexts and absolute remote URLs use scoped access
|
||||||
if (cliToken && !headers.has(CLI_TOKEN_HEADER)) {
|
// tokens and must never receive this machine-bound local credential.
|
||||||
headers.set(CLI_TOKEN_HEADER, cliToken);
|
const destinationUrl = opts.destinationUrl ?? getBaseUrl(opts);
|
||||||
|
if (!isLoopbackUrl(destinationUrl)) {
|
||||||
|
headers.delete(CLI_TOKEN_HEADER);
|
||||||
|
} else {
|
||||||
|
const cliToken = opts.cliToken ?? process.env.OMNIROUTE_CLI_TOKEN ?? (await getCliToken());
|
||||||
|
if (cliToken && !headers.has(CLI_TOKEN_HEADER)) {
|
||||||
|
headers.set(CLI_TOKEN_HEADER, cliToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (opts.idempotencyKey && !headers.has("idempotency-key")) {
|
if (opts.idempotencyKey && !headers.has("idempotency-key")) {
|
||||||
headers.set("idempotency-key", opts.idempotencyKey);
|
headers.set("idempotency-key", opts.idempotencyKey);
|
||||||
@@ -195,8 +215,12 @@ function fetchOnce(url, init, timeoutMs) {
|
|||||||
export async function apiFetch(path, opts = {}) {
|
export async function apiFetch(path, opts = {}) {
|
||||||
const method = String(opts.method || "GET").toUpperCase();
|
const method = String(opts.method || "GET").toUpperCase();
|
||||||
const url = resolveUrl(path, opts);
|
const url = resolveUrl(path, opts);
|
||||||
const headers = await buildHeaders(opts);
|
const headers = await buildHeaders({ ...opts, destinationUrl: url });
|
||||||
const body = serializeBody(opts.body, headers);
|
const body = serializeBody(opts.body, headers);
|
||||||
|
// Undici preserves custom headers across cross-origin redirects. A local server
|
||||||
|
// redirect must never turn the loopback machine credential into an outbound
|
||||||
|
// secret, so fail redirects whenever this header is present.
|
||||||
|
const redirect = headers.has(CLI_TOKEN_HEADER) ? "error" : opts.redirect;
|
||||||
const timeout =
|
const timeout =
|
||||||
opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000);
|
opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000);
|
||||||
const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts);
|
const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts);
|
||||||
@@ -205,7 +229,7 @@ export async function apiFetch(path, opts = {}) {
|
|||||||
let lastErr;
|
let lastErr;
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
const res = await fetchOnce(url, { method, headers, body }, timeout);
|
const res = await fetchOnce(url, { method, headers, body, redirect }, timeout);
|
||||||
if (res.ok) return enrichResponse(res, opts);
|
if (res.ok) return enrichResponse(res, opts);
|
||||||
if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) {
|
if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) {
|
||||||
const delay = computeBackoff(attempt, res.headers.get("retry-after"));
|
const delay = computeBackoff(attempt, res.headers.get("retry-after"));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { withRuntime } from "../runtime.mjs";
|
|||||||
import { t } from "../i18n.mjs";
|
import { t } from "../i18n.mjs";
|
||||||
import { apiFetch } from "../api.mjs";
|
import { apiFetch } from "../api.mjs";
|
||||||
import { emit } from "../output.mjs";
|
import { emit } from "../output.mjs";
|
||||||
|
import { resolveComboModels, collectModel } from "./comboModels.mjs";
|
||||||
|
|
||||||
const VALID_STRATEGIES = [
|
const VALID_STRATEGIES = [
|
||||||
"priority",
|
"priority",
|
||||||
@@ -125,10 +126,31 @@ export function registerCombo(program) {
|
|||||||
.choices(VALID_STRATEGIES)
|
.choices(VALID_STRATEGIES)
|
||||||
.default("priority")
|
.default("priority")
|
||||||
)
|
)
|
||||||
|
.option(
|
||||||
|
"--models <spec>",
|
||||||
|
"Models for the combo: comma-separated provider/model entries, or a JSON array " +
|
||||||
|
'(e.g. --models "openai/gpt-4o,anthropic/claude-3-opus" or ' +
|
||||||
|
'--models \'[{"model":"gpt-4o","providerId":"openai"}]\')'
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--model <spec>",
|
||||||
|
"Add one model to the combo (provider/model or bare model id) — repeatable",
|
||||||
|
collectModel,
|
||||||
|
[]
|
||||||
|
)
|
||||||
.action(async (name, opts, cmd) => {
|
.action(async (name, opts, cmd) => {
|
||||||
const globalOpts = cmd.parent.optsWithGlobals();
|
const globalOpts = cmd.parent.optsWithGlobals();
|
||||||
|
let models;
|
||||||
|
try {
|
||||||
|
models = resolveComboModels(opts);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const exitCode = await runComboCreateCommand(name, opts.strategy, {
|
const exitCode = await runComboCreateCommand(name, opts.strategy, {
|
||||||
...opts,
|
...opts,
|
||||||
|
models,
|
||||||
output: globalOpts.output,
|
output: globalOpts.output,
|
||||||
});
|
});
|
||||||
if (exitCode !== 0) process.exit(exitCode);
|
if (exitCode !== 0) process.exit(exitCode);
|
||||||
@@ -284,12 +306,14 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const models = Array.isArray(opts.models) ? opts.models : [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await withRuntime(async ({ kind, api, db }) => {
|
return await withRuntime(async ({ kind, api, db }) => {
|
||||||
if (kind === "http") {
|
if (kind === "http") {
|
||||||
const res = await api("/api/combos", {
|
const res = await api("/api/combos", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { name, strategy, enabled: true, models: [], config: {} },
|
body: { name, strategy, enabled: true, models, config: {} },
|
||||||
retry: false,
|
retry: false,
|
||||||
acceptNotOk: true,
|
acceptNotOk: true,
|
||||||
});
|
});
|
||||||
@@ -305,7 +329,7 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
|
|||||||
console.error(`Combo '${name}' already exists. Delete it first.`);
|
console.error(`Combo '${name}' already exists. Delete it first.`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
await db.combos.createCombo({ name, strategy, enabled: true, models: [], config: {} });
|
await db.combos.createCombo({ name, strategy, enabled: true, models, config: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(t("combo.created", { name }));
|
console.log(t("combo.created", { name }));
|
||||||
|
|||||||
142
bin/cli/commands/comboModels.mjs
Normal file
142
bin/cli/commands/comboModels.mjs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// Parses the `--models` / `--model` options for `omniroute combo create` (#10954).
|
||||||
|
//
|
||||||
|
// Root cause of #10954: `combo create` only ever registered `--strategy`; the
|
||||||
|
// HTTP body (POST /api/combos) and the local-db fallback (db.combos.createCombo)
|
||||||
|
// both hardcoded `models: []`, so every combo created via the CLI came out
|
||||||
|
// empty regardless of what the operator intended to route to.
|
||||||
|
//
|
||||||
|
// Accepted shapes mirror the server-side Zod union in
|
||||||
|
// `src/shared/validation/schemas/combo.ts` (`comboModelEntry` /
|
||||||
|
// `createComboSchema.models`) so a CLI-built payload never gets rejected by
|
||||||
|
// the API that ultimately validates it:
|
||||||
|
// - a plain string ("provider/model" or a bare model id) — the server's
|
||||||
|
// `normalizeComboModels` (src/lib/combos/steps.ts) already splits the
|
||||||
|
// leading "provider/" segment off a plain string, so passing the raw
|
||||||
|
// token through is sufficient for the common case;
|
||||||
|
// - a structured `{ kind?: "model", model, providerId?, provider?, ... }`
|
||||||
|
// object;
|
||||||
|
// - a structured `{ kind: "combo-ref", comboName, ... }` object (nested
|
||||||
|
// combo reference).
|
||||||
|
//
|
||||||
|
// The CLI (bin/cli/**) ships as plain `.mjs` with relative-only imports — no
|
||||||
|
// `@/` path aliases and no TS transpilation at runtime — so importing the
|
||||||
|
// real Zod schema from `src/shared/validation/schemas/combo.ts` is not
|
||||||
|
// viable here. This module instead validates the same minimal shape by hand
|
||||||
|
// and stays a thin, independently testable unit.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates one already-parsed combo model entry against the shape accepted
|
||||||
|
* by `comboModelEntry` (string | model-step | combo-ref). Throws with a
|
||||||
|
* 1-based, human-readable position when the entry does not match.
|
||||||
|
*
|
||||||
|
* @param {unknown} entry
|
||||||
|
* @param {number} index
|
||||||
|
* @returns {string | Record<string, unknown>}
|
||||||
|
*/
|
||||||
|
export function validateComboModelEntryShape(entry, index) {
|
||||||
|
const position = index + 1;
|
||||||
|
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
throw new Error(`--models entry #${position}: empty model string`);
|
||||||
|
}
|
||||||
|
if (trimmed.length > 300) {
|
||||||
|
throw new Error(`--models entry #${position}: model string exceeds 300 characters`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
throw new Error(`--models entry #${position}: must be a string or a JSON object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = entry.kind;
|
||||||
|
|
||||||
|
if (kind === "combo-ref") {
|
||||||
|
if (typeof entry.comboName !== "string" || entry.comboName.trim().length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`--models entry #${position}: kind "combo-ref" requires a non-empty "comboName"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind !== undefined && kind !== "model") {
|
||||||
|
throw new Error(`--models entry #${position}: unknown "kind" value ${JSON.stringify(kind)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry.model !== "string" || entry.model.trim().length === 0) {
|
||||||
|
throw new Error(`--models entry #${position}: requires a non-empty "model"`);
|
||||||
|
}
|
||||||
|
if (entry.providerId !== undefined && typeof entry.providerId !== "string") {
|
||||||
|
throw new Error(`--models entry #${position}: "providerId" must be a string`);
|
||||||
|
}
|
||||||
|
if (entry.provider !== undefined && typeof entry.provider !== "string") {
|
||||||
|
throw new Error(`--models entry #${position}: "provider" must be a string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses one `--models` spec — either a JSON array (`--models '[{"model":"gpt-4o"}]'`)
|
||||||
|
* or a comma-separated list of provider/model tokens
|
||||||
|
* (`--models 'openai/gpt-4o,anthropic/claude-3-opus'`) — into an array of
|
||||||
|
* combo model entries.
|
||||||
|
*
|
||||||
|
* @param {string} spec
|
||||||
|
* @returns {Array<string | Record<string, unknown>>}
|
||||||
|
*/
|
||||||
|
export function parseModelsSpec(spec) {
|
||||||
|
const trimmed = String(spec ?? "").trim();
|
||||||
|
if (trimmed.length === 0) return [];
|
||||||
|
|
||||||
|
if (trimmed.startsWith("[")) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`--models: invalid JSON array (${err.message})`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error("--models: JSON value must be an array");
|
||||||
|
}
|
||||||
|
return parsed.map((entry, i) => validateComboModelEntryShape(entry, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
.split(",")
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length > 0)
|
||||||
|
.map((token, i) => validateComboModelEntryShape(token, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the final `models` array for `combo create` from Commander opts:
|
||||||
|
* `--models <csv-or-json>` and/or repeatable `--model <spec>`.
|
||||||
|
*
|
||||||
|
* @param {{ models?: string, model?: string[] }} opts
|
||||||
|
* @returns {Array<string | Record<string, unknown>>}
|
||||||
|
*/
|
||||||
|
export function resolveComboModels(opts = {}) {
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
if (typeof opts.models === "string" && opts.models.trim().length > 0) {
|
||||||
|
result.push(...parseModelsSpec(opts.models));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(opts.model)) {
|
||||||
|
opts.model.forEach((token, i) => {
|
||||||
|
result.push(validateComboModelEntryShape(String(token).trim(), i));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commander `collect`-style reducer for the repeatable `--model` option. */
|
||||||
|
export function collectModel(value, previous) {
|
||||||
|
previous.push(value);
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { createDecipheriv, scryptSync } from "node:crypto";
|
import { createDecipheriv, scryptSync } from "node:crypto";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { isLoopbackUrl } from "../api.mjs";
|
||||||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||||
|
import { getCliToken, CLI_TOKEN_HEADER } from "../utils/cliToken.mjs";
|
||||||
import { printHeading } from "../io.mjs";
|
import { printHeading } from "../io.mjs";
|
||||||
import { t } from "../i18n.mjs";
|
import { t } from "../i18n.mjs";
|
||||||
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
|
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
|
||||||
@@ -378,11 +380,11 @@ function checkMemory() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWithTimeout(url) {
|
async function fetchWithTimeout(url, options = {}) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
return await fetch(url, { signal: controller.signal });
|
return await fetch(url, { ...options, signal: controller.signal });
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
@@ -471,6 +473,98 @@ async function checkServerLiveness(options = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkMachineTokenAuth(options = {}) {
|
||||||
|
if (process.env.OMNIROUTE_DISABLE_CLI_TOKEN === "true") {
|
||||||
|
return warn("CLI machine token", "CLI machine-token authentication is disabled", {
|
||||||
|
derived: false,
|
||||||
|
accepted: false,
|
||||||
|
disabled: true,
|
||||||
|
tokenExposed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(resolveLivenessUrl(options));
|
||||||
|
if (
|
||||||
|
!["http:", "https:"].includes(parsed.protocol) ||
|
||||||
|
parsed.username ||
|
||||||
|
parsed.password ||
|
||||||
|
!isLoopbackUrl(parsed.toString())
|
||||||
|
) {
|
||||||
|
return warn(
|
||||||
|
"CLI machine token",
|
||||||
|
"Machine-token probes are limited to HTTP(S) loopback endpoints",
|
||||||
|
{ derived: false, accepted: false, tokenExposed: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parsed.pathname = "/api/cli/whoami";
|
||||||
|
parsed.search = "";
|
||||||
|
parsed.hash = "";
|
||||||
|
url = parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return warn("CLI machine token", "Could not resolve the management endpoint", {
|
||||||
|
derived: false,
|
||||||
|
accepted: false,
|
||||||
|
tokenExposed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getCliToken();
|
||||||
|
if (!token) {
|
||||||
|
return fail(
|
||||||
|
"CLI machine token",
|
||||||
|
"Could not derive a machine token; verify the node-machine-id runtime is installed",
|
||||||
|
{ derived: false, accepted: false, tokenExposed: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithTimeout(url, {
|
||||||
|
headers: { [CLI_TOKEN_HEADER]: token },
|
||||||
|
redirect: "error",
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
return ok("CLI machine token", "Server accepted the local machine token", {
|
||||||
|
url,
|
||||||
|
status: response.status,
|
||||||
|
derived: true,
|
||||||
|
accepted: true,
|
||||||
|
tokenExposed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
return warn(
|
||||||
|
"CLI machine token",
|
||||||
|
"Server rejected the local machine token; if the CLI and server are on different hosts or container boundaries, run `omniroute connect <host> --key <oma_live_...>`",
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
status: response.status,
|
||||||
|
derived: true,
|
||||||
|
accepted: false,
|
||||||
|
containerBoundaryLikely: true,
|
||||||
|
tokenExposed: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return warn("CLI machine token", `Machine-token probe returned HTTP ${response.status}`, {
|
||||||
|
url,
|
||||||
|
status: response.status,
|
||||||
|
derived: true,
|
||||||
|
accepted: false,
|
||||||
|
tokenExposed: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return warn("CLI machine token", "Machine-token endpoint could not be reached", {
|
||||||
|
url,
|
||||||
|
status: 0,
|
||||||
|
derived: true,
|
||||||
|
accepted: false,
|
||||||
|
tokenExposed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function collectDoctorChecks(context = {}, options = {}) {
|
export async function collectDoctorChecks(context = {}, options = {}) {
|
||||||
const rootDir =
|
const rootDir =
|
||||||
context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||||
@@ -488,6 +582,7 @@ export async function collectDoctorChecks(context = {}, options = {}) {
|
|||||||
|
|
||||||
if (!options.skipLiveness) {
|
if (!options.skipLiveness) {
|
||||||
checks.push(await checkServerLiveness(options));
|
checks.push(await checkServerLiveness(options));
|
||||||
|
checks.push(await checkMachineTokenAuth(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLI tool health checks
|
// CLI tool health checks
|
||||||
|
|||||||
@@ -129,7 +129,34 @@ function buildTestInput(connection, apiKey) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runProviderTest(db, connection) {
|
async function testProviderConnectionThroughServer(connection) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}/test`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
retry: false,
|
||||||
|
timeout: 30000,
|
||||||
|
acceptNotOk: true,
|
||||||
|
});
|
||||||
|
const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` };
|
||||||
|
return {
|
||||||
|
connection: publicConnection(connection),
|
||||||
|
...data,
|
||||||
|
valid: data.valid === true,
|
||||||
|
skipped: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
connection: publicConnection(connection),
|
||||||
|
valid: false,
|
||||||
|
skipped: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
statusCode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProviderTest(db, connection, { serverUp = false } = {}) {
|
||||||
// Only API-key connections can be probed with a stored credential. OAuth /
|
// Only API-key connections can be probed with a stored credential. OAuth /
|
||||||
// no-auth connections have nothing for testProviderApiKey() to send, and
|
// no-auth connections have nothing for testProviderApiKey() to send, and
|
||||||
// getProviderApiKey() throws for them by design — reporting that as a FAILED
|
// getProviderApiKey() throws for them by design — reporting that as a FAILED
|
||||||
@@ -151,6 +178,9 @@ async function runProviderTest(db, connection) {
|
|||||||
// means the CLI has no probe recipe, not that the provider is unhealthy.
|
// means the CLI has no probe recipe, not that the provider is unhealthy.
|
||||||
// Persisting it would overwrite a good test_status with a failure.
|
// Persisting it would overwrite a good test_status with a failure.
|
||||||
if (result.unsupported) {
|
if (result.unsupported) {
|
||||||
|
if (serverUp) {
|
||||||
|
return testProviderConnectionThroughServer(connection);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
connection: publicConnection(connection),
|
connection: publicConnection(connection),
|
||||||
...result,
|
...result,
|
||||||
@@ -266,6 +296,7 @@ export async function runTestCommand(selector, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runTestAllCommand(opts = {}) {
|
export async function runTestAllCommand(opts = {}) {
|
||||||
|
const serverUp = await isServerUp();
|
||||||
const { db } = await openOmniRouteDb();
|
const { db } = await openOmniRouteDb();
|
||||||
try {
|
try {
|
||||||
const connections = listProviderConnections(db);
|
const connections = listProviderConnections(db);
|
||||||
@@ -280,7 +311,7 @@ export async function runTestAllCommand(opts = {}) {
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
results.push(await runProviderTest(db, connection));
|
results.push(await runProviderTest(db, connection, { serverUp }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
|
|||||||
@@ -38,12 +38,19 @@ export async function runTestProviderCommand(provider, model, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetProvider = provider || "anthropic";
|
const targetProvider = provider || "anthropic";
|
||||||
const targetModel = model || "claude-haiku-4-5-20251001";
|
const connections = await _loadConnections();
|
||||||
|
if (!connections) return 1;
|
||||||
|
const connection = _resolveConnection(connections, targetProvider, model);
|
||||||
|
if (!connection) {
|
||||||
|
console.error(`Provider connection not found: ${targetProvider}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const targetModel = model || connection.defaultModel;
|
||||||
const repeat = opts.repeat && opts.repeat > 0 ? opts.repeat : 1;
|
const repeat = opts.repeat && opts.repeat > 0 ? opts.repeat : 1;
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
for (let i = 0; i < repeat; i++) {
|
for (let i = 0; i < repeat; i++) {
|
||||||
const result = await _runSingleTest(targetProvider, targetModel);
|
const result = await _runSingleTest(connection, targetModel);
|
||||||
results.push(result);
|
results.push(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,18 +77,10 @@ export async function runTestProviderCommand(provider, model, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function _runAllProviders(opts) {
|
async function _runAllProviders(opts) {
|
||||||
const res = await apiFetch("/api/providers?limit=200", {
|
const loaded = await _loadConnections();
|
||||||
retry: false,
|
if (!loaded) return 1;
|
||||||
timeout: 5000,
|
const connections = loaded.filter(
|
||||||
acceptNotOk: true,
|
(c) => c.isActive !== false && (c.authType === "apikey" || c.testStatus !== "unavailable")
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error(t("test.noServer"));
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
const connections = (data.connections ?? data.providers ?? data.items ?? data).filter(
|
|
||||||
(c) => c.authType === "apikey" || c.testStatus !== "unavailable"
|
|
||||||
);
|
);
|
||||||
if (connections.length === 0) {
|
if (connections.length === 0) {
|
||||||
console.log(t("test.noProviders"));
|
console.log(t("test.noProviders"));
|
||||||
@@ -89,6 +88,7 @@ async function _runAllProviders(opts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const providers = connections.map((c) => ({
|
const providers = connections.map((c) => ({
|
||||||
|
connectionId: c.id,
|
||||||
provider: c.provider ?? c.id,
|
provider: c.provider ?? c.id,
|
||||||
model: c.defaultModel ?? c.model,
|
model: c.defaultModel ?? c.model,
|
||||||
}));
|
}));
|
||||||
@@ -102,8 +102,8 @@ async function _runAllProviders(opts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
providers.map(async ({ provider, model }) => {
|
providers.map(async ({ connectionId, provider, model }) => {
|
||||||
const r = await _runSingleTest(provider, model);
|
const r = await _runSingleTest({ id: connectionId }, model);
|
||||||
return { provider, model, ...r };
|
return { provider, model, ...r };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -123,6 +123,13 @@ async function _runAllProviders(opts) {
|
|||||||
|
|
||||||
async function _runCompare(provider, opts) {
|
async function _runCompare(provider, opts) {
|
||||||
const targetProvider = provider || "anthropic";
|
const targetProvider = provider || "anthropic";
|
||||||
|
const connections = await _loadConnections();
|
||||||
|
if (!connections) return 1;
|
||||||
|
const connection = _resolveConnection(connections, targetProvider);
|
||||||
|
if (!connection) {
|
||||||
|
console.error(`Provider connection not found: ${targetProvider}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
const models = opts.compare
|
const models = opts.compare
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((m) => m.trim())
|
.map((m) => m.trim())
|
||||||
@@ -138,7 +145,7 @@ async function _runCompare(provider, opts) {
|
|||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
const results = [];
|
const results = [];
|
||||||
for (let i = 0; i < repeat; i++) {
|
for (let i = 0; i < repeat; i++) {
|
||||||
const result = await _runSingleTest(targetProvider, model);
|
const result = await _runSingleTest(connection, model);
|
||||||
results.push(result);
|
results.push(result);
|
||||||
}
|
}
|
||||||
rows.push({ model, ..._aggregate(results, true) });
|
rows.push({ model, ..._aggregate(results, true) });
|
||||||
@@ -180,19 +187,55 @@ async function _runCompare(provider, opts) {
|
|||||||
return rows.every((r) => r.success) ? 0 : 1;
|
return rows.every((r) => r.success) ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _runSingleTest(provider, model) {
|
async function _loadConnections() {
|
||||||
|
const res = await apiFetch("/api/providers?limit=200", {
|
||||||
|
retry: false,
|
||||||
|
timeout: 5000,
|
||||||
|
acceptNotOk: true,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(t("test.noServer"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const connections = data.connections ?? data.providers ?? data.items ?? data;
|
||||||
|
if (!Array.isArray(connections)) {
|
||||||
|
console.error(t("test.noServer"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _resolveConnection(connections, selector, model) {
|
||||||
|
const normalized = String(selector || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const active = connections.filter((connection) => connection.isActive !== false);
|
||||||
|
return (
|
||||||
|
active.find((connection) => String(connection.id || "").toLowerCase() === normalized) ??
|
||||||
|
active.find((connection) => String(connection.name || "").toLowerCase() === normalized) ??
|
||||||
|
active.find(
|
||||||
|
(connection) =>
|
||||||
|
String(connection.provider || "").toLowerCase() === normalized &&
|
||||||
|
(!model || connection.defaultModel === model || connection.model === model)
|
||||||
|
) ??
|
||||||
|
active.find((connection) => String(connection.provider || "").toLowerCase() === normalized)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _runSingleTest(connection, model) {
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/v1/providers/test", {
|
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}/test`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { provider, model },
|
body: model ? { validationModelId: model } : {},
|
||||||
retry: false,
|
retry: false,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
acceptNotOk: true,
|
acceptNotOk: true,
|
||||||
});
|
});
|
||||||
const durationMs = Date.now() - startMs;
|
const durationMs = Date.now() - startMs;
|
||||||
const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` };
|
const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` };
|
||||||
return { ...data, durationMs };
|
return { ...data, success: data.valid === true, durationMs };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -121,7 +121,16 @@ function writeLinuxSystemdUnit(cliPath) {
|
|||||||
"Wants=network-online.target",
|
"Wants=network-online.target",
|
||||||
"",
|
"",
|
||||||
"[Service]",
|
"[Service]",
|
||||||
"Type=simple",
|
// Type=notify + WatchdogSec: the server sends READY=1 once listening and
|
||||||
|
// WATCHDOG=1 every 60s; if its event loop ever blocks (frozen process),
|
||||||
|
// the pings stop and systemd kills+restarts the service. NotifyAccess=all
|
||||||
|
// because the pings come from the server child, not the serve supervisor.
|
||||||
|
// Foreground serve only: `--daemon` escapes the cgroup and would break
|
||||||
|
// the notify handshake.
|
||||||
|
"Type=notify",
|
||||||
|
"NotifyAccess=all",
|
||||||
|
"WatchdogSec=180",
|
||||||
|
"TimeoutStartSec=300",
|
||||||
`ExecStart=${buildServeExecLine(cliPath, { tray: false })}`,
|
`ExecStart=${buildServeExecLine(cliPath, { tray: false })}`,
|
||||||
"Restart=on-failure",
|
"Restart=on-failure",
|
||||||
"RestartSec=5",
|
"RestartSec=5",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { render, Box, Text, useInput } from "ink";
|
import { render, Box, Text, useInput } from "ink";
|
||||||
import Spinner from "ink-spinner";
|
import Spinner from "ink-spinner";
|
||||||
|
import { apiFetch } from "../api.mjs";
|
||||||
import { DataTable } from "../tui-components/DataTable.jsx";
|
import { DataTable } from "../tui-components/DataTable.jsx";
|
||||||
import { ProgressBar } from "../tui-components/ProgressBar.jsx";
|
import { ProgressBar } from "../tui-components/ProgressBar.jsx";
|
||||||
|
|
||||||
@@ -31,22 +32,20 @@ const TABLE_SCHEMA = [
|
|||||||
{ key: "error", header: "Error", width: 28, formatter: (v) => (v ? v.slice(0, 26) : "") },
|
{ key: "error", header: "Error", width: 28, formatter: (v) => (v ? v.slice(0, 26) : "") },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function testOne(provider, model, baseUrl, apiKey) {
|
async function testOne(connectionId, model, baseUrl, apiKey) {
|
||||||
const headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
||||||
};
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${baseUrl}/api/v1/providers/test`, {
|
const res = await apiFetch(`/api/providers/${encodeURIComponent(connectionId)}/test`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
body: model ? { validationModelId: model } : {},
|
||||||
body: JSON.stringify({ provider, model }),
|
baseUrl,
|
||||||
signal: AbortSignal.timeout(30000),
|
token: apiKey,
|
||||||
|
timeout: 30000,
|
||||||
|
acceptNotOk: true,
|
||||||
});
|
});
|
||||||
const latencyMs = Date.now() - start;
|
const latencyMs = Date.now() - start;
|
||||||
const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` };
|
const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` };
|
||||||
return { status: data.success ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error };
|
return { status: data.valid ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
return {
|
return {
|
||||||
@@ -63,6 +62,7 @@ function ProvidersTestAllApp({ providers, baseUrl, apiKey, concurrency = 4, onEx
|
|||||||
const [rows, setRows] = useState(() =>
|
const [rows, setRows] = useState(() =>
|
||||||
providers.map((p, i) => ({
|
providers.map((p, i) => ({
|
||||||
id: i,
|
id: i,
|
||||||
|
connectionId: p.connectionId ?? p.id,
|
||||||
provider: p.provider ?? p.id ?? String(p),
|
provider: p.provider ?? p.id ?? String(p),
|
||||||
model: p.model ?? p.defaultModel ?? "",
|
model: p.model ?? p.defaultModel ?? "",
|
||||||
status: STATUS.PENDING,
|
status: STATUS.PENDING,
|
||||||
@@ -91,7 +91,7 @@ function ProvidersTestAllApp({ providers, baseUrl, apiKey, concurrency = 4, onEx
|
|||||||
const row = queue[cursor++];
|
const row = queue[cursor++];
|
||||||
running++;
|
running++;
|
||||||
update(row.id, { status: STATUS.RUNNING });
|
update(row.id, { status: STATUS.RUNNING });
|
||||||
testOne(row.provider, row.model, resolved, apiKey).then((result) => {
|
testOne(row.connectionId, row.model, resolved, apiKey).then((result) => {
|
||||||
update(row.id, result);
|
update(row.id, result);
|
||||||
running--;
|
running--;
|
||||||
nextSlot();
|
nextSlot();
|
||||||
|
|||||||
@@ -12,25 +12,39 @@ function getActiveSalt() {
|
|||||||
return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT;
|
return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCliToken() {
|
export function deriveCliToken(machineIdModule, salt) {
|
||||||
const salt = getActiveSalt();
|
|
||||||
if (_cached !== null && _cachedSalt === salt) return _cached;
|
|
||||||
try {
|
try {
|
||||||
// node-machine-id is CommonJS: under `await import()` its exports land on
|
// node-machine-id is CommonJS: under `await import()` its exports land on
|
||||||
// `.default`, so destructuring `machineIdSync` off the namespace yields
|
// `.default`, so destructuring `machineIdSync` off the namespace yields
|
||||||
// undefined and calling it throws — which the catch below turned into an
|
// undefined and calling it throws — which the catch below turned into an
|
||||||
// empty token, silently disabling CLI auth for every management request.
|
// empty token, silently disabling CLI auth for every management request.
|
||||||
// Same resolution order as src/lib/machineToken.ts.
|
// Same resolution order as src/lib/machineToken.ts.
|
||||||
const mod = await import("node-machine-id");
|
const machineIdSync =
|
||||||
const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync;
|
machineIdModule?.machineIdSync || machineIdModule?.default?.machineIdSync;
|
||||||
if (typeof machineIdSync !== "function") throw new Error("machine-id API unavailable");
|
if (typeof machineIdSync !== "function") return "";
|
||||||
// machineIdSync(true) returns the original unhashed hardware ID — mirrors
|
// machineIdSync(true) returns the original unhashed hardware ID — mirrors
|
||||||
// getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening).
|
// getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening).
|
||||||
const mid = machineIdSync(true);
|
const rawId = machineIdSync(true);
|
||||||
_cached = crypto.createHmac("sha256", mid).update(salt).digest("hex");
|
if (!rawId) return "";
|
||||||
|
return crypto.createHmac("sha256", rawId).update(salt).digest("hex");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCliToken() {
|
||||||
|
const salt = getActiveSalt();
|
||||||
|
if (_cached !== null && _cachedSalt === salt) return _cached;
|
||||||
|
try {
|
||||||
|
const imported = await import("node-machine-id");
|
||||||
|
const token = deriveCliToken(imported, salt);
|
||||||
|
if (!token) {
|
||||||
|
// Swallowing here changes control flow (every management call goes out
|
||||||
|
// unauthenticated and 401s), so leave a breadcrumb rather than failing mute.
|
||||||
|
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled");
|
||||||
|
}
|
||||||
|
_cached = token;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Swallowing here changes control flow (every management call goes out
|
|
||||||
// unauthenticated and 401s), so leave a breadcrumb rather than failing mute.
|
|
||||||
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled:", e);
|
console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled:", e);
|
||||||
_cached = "";
|
_cached = "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ function loadEnvFile() {
|
|||||||
addEnvPath(join(ROOT, ".env"));
|
addEnvPath(join(ROOT, ".env"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const keyOrigin = new Map();
|
||||||
|
const shadowed = new Map();
|
||||||
|
|
||||||
for (const envPath of envPaths) {
|
for (const envPath of envPaths) {
|
||||||
try {
|
try {
|
||||||
if (existsSync(envPath)) {
|
if (existsSync(envPath)) {
|
||||||
@@ -131,19 +134,31 @@ function loadEnvFile() {
|
|||||||
const key = trimmed.slice(0, eqIdx).trim();
|
const key = trimmed.slice(0, eqIdx).trim();
|
||||||
if (process.env[key] === undefined) {
|
if (process.env[key] === undefined) {
|
||||||
process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1));
|
process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1));
|
||||||
|
keyOrigin.set(key, envPath);
|
||||||
|
} else if (!shadowed.has(key)) {
|
||||||
|
// The line is inert: something set this key first. Report it once
|
||||||
|
// per key, whether the winner was an earlier file or the process
|
||||||
|
// environment (#6194: a shell's own HOSTNAME beat the .env and the
|
||||||
|
// server bound to the wrong address in silence).
|
||||||
|
shadowed.set(key, { winner: keyOrigin.get(key) ?? null, loser: envPath });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadedEnvPaths.push(envPath);
|
loadedEnvPaths.push(envPath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Ignore errors reading env files.
|
console.warn(` \x1b[33m⚠ Could not read ${envPath}: ${err?.message ?? err}\x1b[0m`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const envPath of loadedEnvPaths) {
|
for (const envPath of loadedEnvPaths) {
|
||||||
console.log(` \x1b[2m📋 Loaded env from ${envPath}\x1b[0m`);
|
console.log(` \x1b[2m📋 Loaded env from ${envPath}\x1b[0m`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [key, { winner, loser }] of shadowed) {
|
||||||
|
const setter = winner ? winner : "the environment";
|
||||||
|
console.warn(` \x1b[33m⚠ ${key} in ${loser} is ignored, ${setter} set it first\x1b[0m`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadEnvFile();
|
loadEnvFile();
|
||||||
|
|||||||
1
changelog.d/features/10303-healthz-event-loop-lag.md
Normal file
1
changelog.d/features/10303-healthz-event-loop-lag.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **feat(resilience):** warn when `/healthz` is served under event-loop lag ≥200ms so a slow 200 is visible as sick, not healthy ([#10303](https://github.com/diegosouzapw/OmniRoute/issues/10303))
|
||||||
1
changelog.d/features/10316-livez-endpoint.md
Normal file
1
changelog.d/features/10316-livez-endpoint.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **feat(docker):** add `GET`/`HEAD` `/livez` as a process-alive probe, distinct from `/healthz` readiness ([#10316](https://github.com/diegosouzapw/OmniRoute/issues/10316))
|
||||||
1
changelog.d/features/10587-ogg-speech-alias.md
Normal file
1
changelog.d/features/10587-ogg-speech-alias.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **feat(providers):** accept `response_format=ogg` on `/v1/audio/speech` as an alias for the existing Opus/Ogg encoder ([#10587](https://github.com/diegosouzapw/OmniRoute/issues/10587))
|
||||||
1
changelog.d/features/10662-systemd-notify.md
Normal file
1
changelog.d/features/10662-systemd-notify.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- feat(server): emit systemd sd_notify READY/WATCHDOG/STOPPING (generated unit becomes Type=notify with WatchdogSec=180) so a frozen server process is killed and restarted by systemd instead of lingering undetected
|
||||||
2
changelog.d/features/10668-newapi-gateway-protocols.md
Normal file
2
changelog.d/features/10668-newapi-gateway-protocols.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- **feat(providers):** add the TabiToken NewAPI gateway (`tabitoken`) and teach the existing HCNSec entry (`hcnsec`) the three further protocols it actually serves. TabiToken leaves the NewAPI pricing endpoint public, so its catalog is read from the host rather than guessed: four Claude models, each reporting the Anthropic and OpenAI protocols. HCNSec shipped OpenAI-only; probing the host showed `/v1/messages`, `/v1/responses` and the Gemini `/v1beta` path all reach its token layer, so each is now declared as an alternate format — with its default format, base URL, auth scheme and regional catalog classification untouched. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil
|
||||||
|
- **feat(sse):** allow an alternate protocol to build its own upstream URL. `AlternateFormat` gained an optional `urlBuilder`, because the Gemini protocol carries the model inside the path (`{base}/{model}:generateContent`) and the existing `chatPath`/`urlSuffix` fields are constants that cannot express it. The route builder is extracted as `buildGeminiGenerateContentUrl` and shared with the native `gemini` provider so the two consumers cannot drift on the `?alt=sse` streaming suffix. ([#10668](https://github.com/diegosouzapw/OmniRoute/pull/10668)) — thanks @yawar-aquil
|
||||||
1
changelog.d/features/10670-call-logs-error-type.md
Normal file
1
changelog.d/features/10670-call-logs-error-type.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **feat(call_logs):** persist the per-call error family in `call_logs.error_type` and expose a failure breakdown (`errorBreakdown`) in the usage analytics endpoint, reusing the existing production classifier ([#10670](https://github.com/diegosouzapw/OmniRoute/issues/10670))
|
||||||
1
changelog.d/features/10677-egress-sharing-summary.md
Normal file
1
changelog.d/features/10677-egress-sharing-summary.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **feat(proxy):** the proxy-health sweep and `GET /api/settings/proxies/egress` now report an anonymous summary of egress-IP sharing — how many rotation groups share an egress IP and the largest number of accounts behind one IP — computed from persisted `proxy_logs` over a 24h window. No IPs and no account identities by default; `PROXY_LOG_INCLUDE_IPS=true` restores raw details. ([#10677](https://github.com/diegosouzapw/OmniRoute/issues/10677))
|
||||||
1
changelog.d/features/10869-combo-patch-verb.md
Normal file
1
changelog.d/features/10869-combo-patch-verb.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- feat(api): accept PATCH on /api/combos/[id], the verb the OpenAPI spec already documents (#10869)
|
||||||
1
changelog.d/features/10896-glm-5.3.md
Normal file
1
changelog.d/features/10896-glm-5.3.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **feat(sse):** add GLM-5.3 support (`glm-5.3`, `glm-5.3-high`, `glm-5.3-low`) across the z.ai first-party providers, mapping the upstream `reasoning_effort` request parameter to the existing 5.2 tier UX ([#10896](https://github.com/diegosouzapw/OmniRoute/pull/10896)) — thanks @phuongddx
|
||||||
1
changelog.d/features/10897-home-recent-requests.md
Normal file
1
changelog.d/features/10897-home-recent-requests.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **feat(home):** add a live **Recent Requests** panel beside the home Provider Topology (polls `GET /api/usage/call-logs?excludeTests=1` every ~3s, gated by the topology appearance toggle + page visibility). `excludeTests` is now an allowlist of real provider inference (`/v1/%` or `/api/v1/%`), applied before `LIMIT`, so connection-test/model-sync/management rows can never leak into the feed ([#10897](https://github.com/diegosouzapw/OmniRoute/pull/10897), extracted from [#8450](https://github.com/diegosouzapw/OmniRoute/pull/8450)) — thanks @nguyenha935
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **feat(rankings):** free provider rankings now expose a `reliability` field (raw `testStatus`/`rateLimitedUntil` per connection plus a `healthy`/`degraded`/`down` state, reusing the `ProviderHealthState` vocabulary of the provider health matrix) when the configured/available filters are active — derived from already-loaded data, without touching the ranking order ([#10909](https://github.com/diegosouzapw/OmniRoute/pull/10909))
|
||||||
8
changelog.d/features/10920-egress-ip-lock.md
Normal file
8
changelog.d/features/10920-egress-ip-lock.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
- `feat(resilience)`: when an allowlisted provider (opencode family) answers
|
||||||
|
429 classified `quota_exhausted` or `rate_limit_exceeded` and its free-tier
|
||||||
|
quota is bucketed by egress IP (#9611), every connection of that family
|
||||||
|
sharing the IP is cooled down together before the rotation tries them — one
|
||||||
|
guaranteed-failed upstream call per episode instead of N, on the combo path
|
||||||
|
as well. For the allowlisted family a 429 now cools the connection instead
|
||||||
|
of locking a single model. Exclusive allowlist, never terminal, best-effort
|
||||||
|
when the egress IP is unknown (#10920).
|
||||||
1
changelog.d/features/10926-rankings-usage-reliability.md
Normal file
1
changelog.d/features/10926-rankings-usage-reliability.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **feat(rankings):** free provider rankings can now report what each provider actually served — `reliability.usage` (requests, successes, success rate over a window) behind the opt-in `withUsage`/`usageRange` query parameters, so a provider that answers every call with an error is no longer described as healthy ([#10926](https://github.com/diegosouzapw/OmniRoute/pull/10926))
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- **feat(credential-health):** pace the credential health sweep per connection via `provider_connections.healthCheckInterval` (minutes, 0 = never), with `CREDENTIAL_HEALTH_CHECK_INTERVAL` as the global default ([#8443](https://github.com/diegosouzapw/OmniRoute/issues/8443))
|
||||||
|
- **behavior change:** `healthCheckInterval` is a shared column — it paces both the OAuth token refresh and the credential health sweep, and `0` disables both. The connection editor defaults it to 60, so configured OAuth connections are now credential-checked at 60min instead of the previous ~10min (aligned with the probe-volume goal of #8443)
|
||||||
1
changelog.d/features/command-code-reasoning-efforts.md
Normal file
1
changelog.d/features/command-code-reasoning-efforts.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- feat(command-code): advertise low/medium/high/xhigh/max reasoning-effort suffixes for reasoning-capable models in the catalog and Combo Builder, with request-time resolution to reasoning_effort
|
||||||
1
changelog.d/features/cursor-agent-image-provider.md
Normal file
1
changelog.d/features/cursor-agent-image-provider.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- feat(sse): add Cursor plan image generation via Agent CLI (`IMAGE_PROVIDERS.cursor`, format `cursor-agent-image`), reusing the chat Cursor OAuth connection
|
||||||
1
changelog.d/features/disable-context-window-checks.md
Normal file
1
changelog.d/features/disable-context-window-checks.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- feat(routing): add the default-off `DISABLE_CONTEXT_WINDOW_CHECKS` feature flag to let operators bypass OmniRoute's local context-window and max-input-token check for direct single-model requests, leaving upstream limits, prompt compression, and output-token caps intact.
|
||||||
1
changelog.d/features/m365-copilot-tool-calls.md
Normal file
1
changelog.d/features/m365-copilot-tool-calls.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **feat(providers):** copilot-m365-web now supports OpenAI tool calling — a router planning turn asks the substrate model (as a tool-selection assistant emitting `CALL_TOOL: name({...})` / `NO_TOOL_NEEDED` text, which bypasses its plugin-registry refusal) and validated decisions surface as `tool_calls` with `finish_reason: "tool_calls"` in both stream and non-stream modes; also flattens the full message history (assistant `tool_calls` + compacted tool results) so multi-turn agent loops keep context, replies to SignalR `type:6` keepalives, surfaces `type:3` error frames instead of a silent empty `stop`, and suppresses `writeAtCursor` text from tool-progress frames
|
||||||
1
changelog.d/features/opencode-go-muse-spark-efforts.md
Normal file
1
changelog.d/features/opencode-go-muse-spark-efforts.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- feat(opencode-go): expose Muse Spark 1.2 Contributor reasoning-effort aliases (minimal/low/medium/high/xhigh) in the Combo Builder
|
||||||
1
changelog.d/features/per-connection-upstream-timeout.md
Normal file
1
changelog.d/features/per-connection-upstream-timeout.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **feat(providers):** restore the operator-owned upstream timeout tier per connection via `providerSpecificData.timeoutMs` (preempts the maintainer-only model/provider registry tiers and the global `FETCH_TIMEOUT_MS`), and make the combo per-target timeout ceiling follow the selected connection
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- fix(domain): stop treating an unreported Antigravity quota fraction (`fractionReported:false`) as 0% remaining in `quotaCache.ts`, which was falsely marking every fresh/newly-connected account as exhausted and blocking multi-account rotation (#10095)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(sse):** Responses-passthrough `response.completed` snapshots now drop `phase:"commentary"` items the same way live SSE frames already do, so the terminal `response.output` array no longer echoes internal commentary text that was already suppressed from the stream (#10156).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(routing):** keep approximate Combo context estimates advisory so requests reach concrete targets instead of returning a pre-dispatch 400 ([#10162](https://github.com/diegosouzapw/OmniRoute/pull/10162)) — thanks @xz-dev
|
||||||
1
changelog.d/fixes/10345-bare-combo-opencode-ids.md
Normal file
1
changelog.d/fixes/10345-bare-combo-opencode-ids.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(opencode-plugin):** publish bare combo model ids without the plugin provider prefix so OpenCode can select them ([#10345](https://github.com/diegosouzapw/OmniRoute/issues/10345))
|
||||||
1
changelog.d/fixes/10346-empty-pool-warn-once.md
Normal file
1
changelog.d/fixes/10346-empty-pool-warn-once.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(backend):** log `auto/<family> matched no connected models` once per process per label instead of every minute ([#10346](https://github.com/diegosouzapw/OmniRoute/issues/10346))
|
||||||
1
changelog.d/fixes/10353-memory-heap-conflict-warn.md
Normal file
1
changelog.d/fixes/10353-memory-heap-conflict-warn.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(docker):** warn at boot when `OMNIROUTE_MEMORY_MB` disagrees with `NODE_OPTIONS --max-old-space-size`, and document that the standalone/Docker launcher appends `OMNIROUTE_MEMORY_MB` last ([#10353](https://github.com/diegosouzapw/OmniRoute/issues/10353))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(antigravity):** automatically rotate to a sibling account when one is BYOP (GCP Project ID required, `gcp_project_required` 422) — the account is excluded from selection for 24h and the request succeeds via another account instead of failing fast; the actionable 422 is surfaced only when no sibling exists (follow-up to the #10424 BYOP fast-fail) ([#10470](https://github.com/diegosouzapw/OmniRoute/pull/10470)) — thanks @rqzbeh
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(network):** direct (no-proxy) egress now bounds each attempt's response-start window (default 30s, `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS`) and retries once on a fresh no-keep-alive socket, so a silently-dropped pooled keep-alive connection can no longer stall direct providers (opencode-go, command-code) until a service restart ([#10214](https://github.com/diegosouzapw/OmniRoute/issues/10214))
|
||||||
1
changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md
Normal file
1
changelog.d/fixes/10536-llmlingua-2-2.0.5-drop-tfjs.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(deps):** upgrade `@atjsh/llmlingua-2` from 2.0.3 to 2.0.5 and remove `@tensorflow/tfjs` from the LLMLingua SLM stack — 2.0.5 adds official Transformers.js v4 support (peers `@huggingface/transformers` at `^3.5.2 || ^4.0.0`) and 2.0.4+ no longer requires TensorFlow.js, restoring compatibility with OmniRoute's Transformers.js v4 while dropping the largest single contributor to the optional runtime footprint ([#10536](https://github.com/diegosouzapw/OmniRoute/issues/10536))
|
||||||
1
changelog.d/fixes/10550-responses-reasoning-transport.md
Normal file
1
changelog.d/fixes/10550-responses-reasoning-transport.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- Preserve portable plaintext reasoning by default across streaming and non-streaming Chat Completions and Responses routes while keeping provider-bound opaque state target-compatible. Combos now drop incompatible continuation reasoning by default and can explicitly skip incompatible targets, while known providers no longer show redundant encrypted-reasoning controls. (#10550)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- fix(dashboard): route the Playground's ChatTab "Send" through the endpoint actually selected in StudioConfigPane (`search`, `web.fetch`, etc.) instead of always POSTing to `/api/v1/chat/completions`, fixing the false "No active credentials for provider" 404 when testing search-only providers (#10592)
|
||||||
1
changelog.d/fixes/10594-freepik-magnific-api.md
Normal file
1
changelog.d/fixes/10594-freepik-magnific-api.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(providers):** Magnific Mystic is now the canonical provider (`/dashboard/providers/magnific`, `magnific/<model>`). It uses the Magnific API (`api.magnific.com` + `x-magnific-api-key`), dashboard Test Connection validates keys without starting a paid generation, and the old `freepik` slug remains a legacy alias ([#10594](https://github.com/diegosouzapw/OmniRoute/pull/10594))
|
||||||
1
changelog.d/fixes/10597-combo-log-error-body.md
Normal file
1
changelog.d/fixes/10597-combo-log-error-body.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(sse):** Include the redacted upstream error body in the per-target COMBO failure log (`Model X failed, trying next`) so operators can triage a 400/500 without reproducing the request ([#10597](https://github.com/diegosouzapw/OmniRoute/issues/10597))
|
||||||
1
changelog.d/fixes/10686-combo-quota-token-limit-await.md
Normal file
1
changelog.d/fixes/10686-combo-quota-token-limit-await.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **Combo routing:** await each connection's token limit before reserving quota. The old lookup treated the `Promise` as a connection and dropped `rateLimitOverrides.tpm` ([#10686](https://github.com/diegosouzapw/OmniRoute/pull/10686)).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(executors):** the Meta AI (muse-spark-web) WebSocket send-message timeout now reports the socket's `readyState` at the moment it fires, so a "Meta AI WS timed out" failure can be told apart as either the connection never opening (`readyState=0`) or opening successfully and then going silent (`readyState=1`) — the exact ambiguity that made #10727 undiagnosable from logs alone (#10727).
|
||||||
1
changelog.d/fixes/10735-search-provider-named-errors.md
Normal file
1
changelog.d/fixes/10735-search-provider-named-errors.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(search):** name `/v1/search` 502s with provider id and sanitized Node cause code, without hostnames ([#10735](https://github.com/diegosouzapw/OmniRoute/issues/10735))
|
||||||
1
changelog.d/fixes/10736-corrupt-rotate-fence.md
Normal file
1
changelog.d/fixes/10736-corrupt-rotate-fence.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(db):** pause call-log rotation and record SQLITE_CORRUPT on `/api/db/health` instead of retrying writes against a malformed pager ([#10736](https://github.com/diegosouzapw/OmniRoute/issues/10736))
|
||||||
1
changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md
Normal file
1
changelog.d/fixes/10765-rtk-unconditional-stats-cpu.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(compression): skip the expensive `createCompressionStats()` pass in RTK when no message was actually compressed, matching every sibling stacked engine (#10765)
|
||||||
1
changelog.d/fixes/10788-ollama-cloud-effort-tiers.md
Normal file
1
changelog.d/fixes/10788-ollama-cloud-effort-tiers.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(open-sse):** declare `supportedThinkingEfforts` (`low`/`medium`/`high`/`max`) on Ollama Cloud's `glm-5.1`, `glm-5.2`, `deepseek-v4-pro` and `deepseek-v4-flash` registry entries so the catalog's `appendSyncedEffortVariants()` pass — which only synthesizes selectable `-low`/`-high`/`-max` model ids from an already-populated `capabilities.effort_tiers` — can expose an effort selector for these reasoning-capable models, matching what `gpt-oss:20b`/`gpt-oss:120b` already had (#10788)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(opencode-plugin):** respect log level in provider.models() catalog path so debug/info/warn messages are suppressed when `features.logLevel` is set to `"error"` ([#10798](https://github.com/diegosouzapw/OmniRoute/pull/10798)) — thanks @tientien17
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(providers):** Keep NVIDIA timeout probes and generic Antigravity/AGY HTTP 400 probes from poisoning credential health while preserving explicit Google geo-block handling ([#10799](https://github.com/diegosouzapw/OmniRoute/pull/10799)) — thanks @Zartharas
|
||||||
1
changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md
Normal file
1
changelog.d/fixes/10815-kiro-oauth-profilearn-dedup.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(db): disambiguate `createProviderConnection()`'s OAuth email dedup by `providerSpecificData.profileArn` in addition to `username`, so adding a second Kiro/AWS profile with the same email creates a new connection instead of silently merging into the first (#10815)
|
||||||
1
changelog.d/fixes/10832-unprefixed-dalle3.md
Normal file
1
changelog.d/fixes/10832-unprefixed-dalle3.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(images):** register OpenAI `dall-e-3` in the image registry so unprefixed `dall-e-3` (and `openai/dall-e-3`) route to OpenAI Images instead of Microsoft Designer Web, and so the chat catalog no longer lists `openai/dall-e-3` as a 128k chat model ([#10832](https://github.com/diegosouzapw/OmniRoute/issues/10832))
|
||||||
1
changelog.d/fixes/10843-outbound-guard-mapped-ipv4.md
Normal file
1
changelog.d/fixes/10843-outbound-guard-mapped-ipv4.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(security):** Outbound URL guard now resolves IPv4-mapped IPv6 literals to their embedded address, so `[::ffff:169.254.169.254]` is refused by the unconditional cloud-metadata block like its dotted spelling; `[::]` is refused alongside `0.0.0.0` ([#10843](https://github.com/diegosouzapw/OmniRoute/pull/10843)) — thanks @ntdat812
|
||||||
1
changelog.d/fixes/10848-image-scan-cookie-bridge.md
Normal file
1
changelog.d/fixes/10848-image-scan-cookie-bridge.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(config): exclude cookie-auth image bridges (chatgpt-web, gemini-web) from the unprefixed model scan so a bare id never silently binds to an unofficial web bridge (#10848)
|
||||||
1
changelog.d/fixes/10849-search-provider-opaque-400.md
Normal file
1
changelog.d/fixes/10849-search-provider-opaque-400.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(api): POST /v1/search now replies with a named `Unknown search provider: <id>` error (and field-named validation messages) instead of an opaque `Invalid request` for unrecognized or short-alias provider ids like `brave`/`serper` (#10849)
|
||||||
1
changelog.d/fixes/10850-readyz-alias.md
Normal file
1
changelog.d/fixes/10850-readyz-alias.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(api):** alias `GET`/`HEAD` `/readyz` to `/healthz` so Kubernetes readiness probes do not 404 ([#10850](https://github.com/diegosouzapw/OmniRoute/issues/10850))
|
||||||
1
changelog.d/fixes/10853-i18n-disabled-mistranslation.md
Normal file
1
changelog.d/fixes/10853-i18n-disabled-mistranslation.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(i18n):** The "Disabled" status no longer renders as the noun for a person with a disability in Japanese, Spanish, Hindi, Polish, Telugu, Urdu and both Chinese locales — 24 strings now use each catalog's existing wording (ja 無効, es Deshabilitado, hi अक्षम, pl Wyłączone, te నిలిపివేయబడింది, ur غیر فعال, zh-CN 已禁用, zh-TW 已停用) ([#10812](https://github.com/diegosouzapw/OmniRoute/issues/10812), [#10853](https://github.com/diegosouzapw/OmniRoute/pull/10853)) — thanks @ntdat812
|
||||||
1
changelog.d/fixes/10854-skills-marketplace-owner.md
Normal file
1
changelog.d/fixes/10854-skills-marketplace-owner.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(skills):** Marketplace-installed skills are available to API-key-scoped requests, including existing SkillsMP and skills.sh installs ([#10854](https://github.com/diegosouzapw/OmniRoute/pull/10854)) — thanks @kriptoburak
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(catalog):** `/v1/models` no longer advertises the built-in `auto/*` ids while auto routing is disabled — they were listed but rejected at request time with `Auto routing is disabled` ([#10831](https://github.com/diegosouzapw/OmniRoute/issues/10831), [#10857](https://github.com/diegosouzapw/OmniRoute/pull/10857)) — thanks @ntdat812
|
||||||
1
changelog.d/fixes/10858-base64-file-token-estimate.md
Normal file
1
changelog.d/fixes/10858-base64-file-token-estimate.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(context):** Base64 file payloads (OpenAI `file` parts, Responses `input_file`, Claude `document` blocks) are budgeted like the Gemini `inlineData` path instead of being counted as prompt text — a ~1MB PDF estimated at 350k tokens and was rejected on the context limit before reaching the provider's document pipeline ([#10840](https://github.com/diegosouzapw/OmniRoute/issues/10840), [#10858](https://github.com/diegosouzapw/OmniRoute/pull/10858)) — thanks @ntdat812
|
||||||
1
changelog.d/fixes/10860-mcp-upstream-fetch-timeout.md
Normal file
1
changelog.d/fixes/10860-mcp-upstream-fetch-timeout.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(mcp):** MCP tool calls that wait on a model provider no longer abort after 10 seconds. `omniRouteFetch` applied a single hardcoded `AbortSignal.timeout(10000)` to every internal hop, and `omniroute_route_request` — which posts to `/v1/chat/completions` and waits on the upstream provider, plus auto-combo candidate probing before a provider is even chosen — passed no signal of its own, so it inherited it. Any route slower than 10s failed from the MCP side while the identical request succeeded through the REST API. `omniroute_web_search` and `omniroute_web_fetch` in the same file already carried an explicit 60s signal, so that value is now shared by all three provider-bound calls instead of being repeated as a literal, while management reads (health, resilience, rate limits, combos, quota, usage) keep their fast-fail 10s budget so a stalled local endpoint still cannot hold a tool call open. Both budgets are overridable through `OMNIROUTE_MCP_FETCH_TIMEOUT_MS` and `OMNIROUTE_MCP_UPSTREAM_TIMEOUT_MS`, replacing the reported workaround of patching the compiled `dist/.build/next/server/chunks/*.js`; a malformed or non-positive override falls back to the default rather than disabling the timeout
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(providers):** importing models with an expired API key now surfaces the credential error instead of reporting "No new models were added". The Import button posts to `/api/providers/{id}/sync-models`, which self-fetches the models route; that route does not fail on an upstream 401 but degrades to a catalog it already has, preferring the cache and using the local catalog only when there is no cache. A provider that imported successfully once therefore has a cache, so an expired key produced `{ source: "cache", warning: "Models probe failed (401) — using cached catalog" }` with HTTP 200 — and the #5460/#5465 degradation guard only recognised the `local_catalog` branch, so model-sync accepted it as a successful discovery, found every cached model already imported, and returned the empty-diff result. Retest does not go through this path, which is why it failed correctly and made the import look like a genuine "nothing to do". The existing rule — a degraded discovery must not be persisted as the synced catalog — is now applied to the branch it missed rather than special-casing 401/403, discriminating on the warning the fallback builder always attaches (an ordinary non-refresh cache hit attaches none, and model-sync always requests `refresh=true`). `isDegradedLocalCatalog` keeps its exact meaning and its existing tests
|
||||||
1
changelog.d/fixes/10866-combo-empty-models.md
Normal file
1
changelog.d/fixes/10866-combo-empty-models.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(api): reject a combo update that removes every model, and store the copilot's combo targets where the router reads them (#10866)
|
||||||
1
changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md
Normal file
1
changelog.d/fixes/10868-proxy-echo-ipv4-fallback.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(proxy):** proxy "Test connection" no longer reports an IPv4-only SOCKS5/SSH proxy as dead. #1255 moved every egress probe from `api.ipify.org` to `api64.ipify.org` so proxies with IPv6 egress could be tested, but `api64` is IPv6-first: a tunnel with no IPv6 route has nothing to connect to, so the probe hung until the caller's deadline and a proxy that was carrying live LLM traffic came back as a failure. Swapping the target to `api4` fixes that case and re-breaks the one #1255 fixed, so the probe now tries the targets in order instead — `api64` first, so a proxy with working IPv6 answers on the first attempt and keeps the exact behaviour #1255 introduced, including which of its addresses is reported (the egress IP is used as an identity to detect accounts of one rotation group sharing an address, so the attempts are sequential rather than raced). The attempts split the budget each call site already enforced, so no probe can take longer than it could before, and each attempt gets its own `AbortController` so exhausting the budget on an unreachable target does not abort the next one. `OMNIROUTE_PROXY_ECHO_URL` pins a single target — including a self-hosted echo — replacing the workaround of rewriting the compiled bundle after every upgrade. The relay branch of the test route still targets `api64` through `x-relay-target`, since that request egresses from the relay worker rather than the operator's tunnel
|
||||||
1
changelog.d/fixes/10870-cli-env-collision.md
Normal file
1
changelog.d/fixes/10870-cli-env-collision.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(cli): warn when a .env line never takes effect, and stop swallowing an unreadable .env (#10870)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(db):** Remove stale MiMoCode provider configuration, including the legacy `mcode` alias, left after provider retirement while preserving historical usage and call logs ([#10873](https://github.com/diegosouzapw/OmniRoute/pull/10873)) — thanks @Zartharas
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(sse):** `getResetAwareProvider()` and the auto-combo quota lookup in `combo.ts` now canonicalize the provider id via `resolveProviderId()` before calling `getQuotaFetcher()`, so a fetcher registered under a provider's canonical id (e.g. `ollama-cloud`, `codex`) is found for combo targets stored under an alias spelling (e.g. `ollamacloud`, `cx`) instead of silently degrading reset-aware/reset-window/auto quota-aware routing to plain priority ordering (#10877)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(provider-health):** Keep unsupported 404/405 validation probes neutral so they do not poison stored credential health or scheduler failure state, while still honoring per-connection health-check pacing ([#10878](https://github.com/diegosouzapw/OmniRoute/pull/10878)) — thanks @Zartharas
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(antigravity):** map Gemini 3.7 Flash tier ids (`gemini-3.7-flash-high/medium/low`, bare `gemini-3.7-flash`) to the upstream `gemini-3.7-flash-tiered` model id Google's Cloud Code endpoint expects, and configure per-tier thinking budgets ([#10882](https://github.com/diegosouzapw/OmniRoute/pull/10882)) — thanks @adevwithpurpose
|
||||||
1
changelog.d/fixes/10887-memory-mcp-tools.md
Normal file
1
changelog.d/fixes/10887-memory-mcp-tools.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(memory):** enable agent memory save/update via MCP tools (`memory_save`/`update`/`search`/`delete` builtins with per-provider schemas, `apiKeyId` optional with caller-principal fallback) and gate server-side memory builtin injection to non-stream requests only ([#10887](https://github.com/diegosouzapw/OmniRoute/pull/10887)) — thanks @Egorich-print
|
||||||
1
changelog.d/fixes/10902-pplx-search-hint-optin.md
Normal file
1
changelog.d/fixes/10902-pplx-search-hint-optin.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(perplexity-web):** make the built-in-search hint appended to every system message opt-in via `OMNIROUTE_PPLX_SEARCH_HINT` (off by default) — Perplexity's answer engine searches anyway, and the hint leaked into replies as meta-commentary for coding clients ([#10902](https://github.com/diegosouzapw/OmniRoute/pull/10902), extracted from [#8634](https://github.com/diegosouzapw/OmniRoute/pull/8634)) — thanks @danscMax
|
||||||
1
changelog.d/fixes/10903-loopback-gate-memory-success.md
Normal file
1
changelog.d/fixes/10903-loopback-gate-memory-success.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(providers):** the loopback readiness gate no longer memorizes a failed probe — the next caller after 30s starts a fresh probe, and a readiness failure is logged once per probe instead of once per caller ([#10903](https://github.com/diegosouzapw/OmniRoute/pull/10903))
|
||||||
1
changelog.d/fixes/10935-cloudflare-relay-path-guard.md
Normal file
1
changelog.d/fixes/10935-cloudflare-relay-path-guard.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(relay):** the Cloudflare proxy-relay worker now resolves `x-relay-path` through the shared `resolveRelayTarget()` guard instead of concatenating it onto the validated target. PR #4643 and its follow-up applied that guard to the Deno and Vercel workers; the Cloudflare generator, ported separately from upstream `decolua/9router` PR #1360, kept `fetch(targetBase + relayPath)`. Validating `x-relay-target` and then concatenating is not sufficient — the path re-points the request past the host that was just checked, through userinfo (`/x@evil.com`), a backslash (`\evil.com`), or a protocol-relative path (`//evil.com/x`). The guard is embedded verbatim under a literal `const resolveRelayTarget =` binding so the hardcoded call site still resolves when the SWC-minified standalone build mangles the source function's own name (#6149), and the new regression test pins that property for this worker by renaming the embedded function and re-evaluating the emitted source. The auth check and the private/loopback target guard are unchanged
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(build):** the `next` Docker image no longer crashes on boot with `ReferenceError: require is not defined in ES module scope`. The standalone `server.js` is CommonJS, but the `postbuild` colocate step was re-adding `"type":"module"` to the standalone root `package.json` (undoing `assembleStandalone`'s strip) to make its ESM worker bundles load. The `type:module` scope is now written per-worker-directory instead of on the root, so `server.js` stays CommonJS while the workers stay ESM ([#10936](https://github.com/diegosouzapw/OmniRoute/pull/10936), fixes [#10933](https://github.com/diegosouzapw/OmniRoute/issues/10933)) — thanks @arminanton
|
||||||
1
changelog.d/fixes/10940-opencode-limit-output.md
Normal file
1
changelog.d/fixes/10940-opencode-limit-output.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(cli): always emit limit.output in generated OpenCode config so schema validation passes for metadata-less models (#10940)
|
||||||
1
changelog.d/fixes/10941-relay-private-host-guard.md
Normal file
1
changelog.d/fixes/10941-relay-private-host-guard.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(relay):** the private/loopback guard the three proxy-relay workers embed no longer misses four host spellings, and now lives in one place instead of three byte-identical inline copies. Driving `new URL(target).hostname` the way the workers do, the previous guard allowed `::` (the unspecified address, which reaches a service bound to the IPv6 loopback), `localhost.` (the FQDN root dot defeated the exact match and every `.localhost`/`.local`/`.internal` suffix rule, so `svc.internal.` slipped too), `::127.0.0.1` (the deprecated IPv4-compatible form — only `::ffff:` was checked), and `feb0::1` (link-local is `fe80::/10`, spanning `fe80`–`febf`, but only the literal `fe80:` spelling matched). The policy moved to `src/lib/proxyRelay/privateHostname.ts` and is embedded verbatim via `Function#toString` under a literal const name, the same mechanism `resolveRelayTarget` already uses for these workers, so a minified standalone build cannot break the call site (#6149). Nothing previously blocked is now allowed. Severity is low — reaching a worker needs the `x-relay-auth` secret and these are edge runtimes where loopback has nothing listening — but the suffix-rule bypass held regardless of runtime
|
||||||
1
changelog.d/fixes/10945-least-used-rotation.md
Normal file
1
changelog.d/fixes/10945-least-used-rotation.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **Account rotation:** make `fallbackStrategy: "least-used"` actually rotate. The strategy sorts on `lastUsedAt` but never wrote it — only the round-robin branch committed — so on a pool where every `last_used_at` was still `NULL` the tie-break fell through to `priority` and returned the same connection on every dispatch ([#10945](https://github.com/diegosouzapw/OmniRoute/issues/10945)).
|
||||||
1
changelog.d/fixes/10947-windows-updater-artifact-name.md
Normal file
1
changelog.d/fixes/10947-windows-updater-artifact-name.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **Desktop auto-update (Windows):** stop the in-app updater 404ing on every release. NSIS used electron-builder's default artifact name, whose spaces GitHub rewrites to `.` on upload while `latest.yml` keeps `-`, so the manifest pointed at `OmniRoute-Setup-X.Y.Z.exe` while the published asset was `OmniRoute.Setup.X.Y.Z.exe`. The name is now set explicitly to the dot form the asset already has, so nothing published changes name ([#10947](https://github.com/diegosouzapw/OmniRoute/issues/10947)).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(catalog):** preserve provider-declared reasoning effort tiers instead of replacing them with generic defaults ([#10953](https://github.com/diegosouzapw/OmniRoute/pull/10953)) — thanks @xz-dev
|
||||||
1
changelog.d/fixes/10954-combo-create-models.md
Normal file
1
changelog.d/fixes/10954-combo-create-models.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(cli): combo create accepts --models and no longer creates empty combos (#10954)
|
||||||
1
changelog.d/fixes/10955-cli-ref-params.md
Normal file
1
changelog.d/fixes/10955-cli-ref-params.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(cli): resolve $ref path params and add PATCH combos requestBody in generated API commands (#10955)
|
||||||
2
changelog.d/fixes/10967-10966-combo-diag-recovery.md
Normal file
2
changelog.d/fixes/10967-10966-combo-diag-recovery.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- fix(sse): combo diagnostics no longer truncate `exhausted_connection` entries to a hardcoded `provider: "unknown"` with the provider prefix eaten by an 8-char slice — the real provider id is preserved and only the connection id is truncated (#10967)
|
||||||
|
- fix(sse): combo terminal failures caused entirely by quota/account-balance exhaustion (including a durable HTTP 403 `insufficient_quota` / `AUTHZ_INSUFFICIENT_BALANCE`) now stamp a stable `quota_exhausted` diagnostics reason with a `switch-combo` recovery hint instead of the misleading default `retry` action (#10966)
|
||||||
1
changelog.d/fixes/10976-skip-default-searxng.md
Normal file
1
changelog.d/fixes/10976-skip-default-searxng.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(search):** skip catalog-default SearXNG `http://localhost:8888/search` so Docker/K8s search does not ECONNREFUSED then 502 into the next provider ([#10976](https://github.com/diegosouzapw/OmniRoute/issues/10976))
|
||||||
1
changelog.d/fixes/10986-reasoning-only-content.md
Normal file
1
changelog.d/fixes/10986-reasoning-only-content.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(command-code): surface reasoning-only output as content when a model emits no text-delta (#10986)
|
||||||
1
changelog.d/fixes/10988-release-v3850-quality-gates.md
Normal file
1
changelog.d/fixes/10988-release-v3850-quality-gates.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(ci):** clear inherited `release/v3.8.50` quality-gate reds on the X Search PR: drop the stale `copilot-m365-web.ts:330` public-creds allowlist, document six missing env vars, register four covering Stryker tap tests, prune leftover ESLint suppressions, replace the phantom `@/lib/db/connections` Utilization import with `getProviderConnectionById`, and fix open-sse/dashboard typecheck regressions in freebuff, browser-backed chat, auth, health matrix, and Monaco ([#10988](https://github.com/diegosouzapw/OmniRoute/pull/10988)).
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user