mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 15:42:12 +03:00
Compare commits
172 Commits
radar-expo
...
fix/10955-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc0adf262 | ||
|
|
3abf8af8c6 | ||
|
|
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 |
72
.env.example
72
.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
|
||||||
@@ -1979,6 +2026,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 +2178,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.
|
||||||
|
|||||||
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, 346 LLM providers, auto-fallback.
|
||||||
|
|
||||||
| Layer | Location | Purpose |
|
| Layer | Location | Purpose |
|
||||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -56,7 +56,7 @@ 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/` | 109 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 |
|
||||||
|
|||||||
@@ -169,6 +169,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
|
||||||
|
|||||||
20
README.md
20
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 → 346 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. 346 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="#-346-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. 346 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 346 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 109 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: 346 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."/>
|
||||||
|
|
||||||
<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 **346-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
|
## 🌐 346 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: **346 providers**, **90+ with a free tier**, **57 free forever**.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -988,6 +988,8 @@ 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).
|
||||||
|
|
||||||
> **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 +1174,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 +1497,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,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/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/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/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/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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- fix(cli): repair hollow externalized package dirs in the nested `<distDir>/node_modules` bundle location too, not just the top-level one, fixing macOS/Linux Electron `ERR_MODULE_NOT_FOUND` on Turbopack-externalized packages (#7346)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **Electron packaged smoke test:** add a cold-restart mode (`ELECTRON_SMOKE_COLD_RESTART=1`, wired blocking on the Linux release leg) that relaunches the packaged app against its own persisted `DATA_DIR` and asserts a native SQLite driver was selected instead of the sql.js WASM fallback, closing the regression-test gap flagged in the stale-ABI `better-sqlite3` investigation ([#7592](https://github.com/diegosouzapw/OmniRoute/issues/7592)).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(images):** retry Codex image generation on a sibling ChatGPT account when the requested model isn't entitled on the current account, instead of failing the request outright ([#8307](https://github.com/diegosouzapw/OmniRoute/pull/8307)).
|
||||||
1
changelog.d/fixes/claude-to-gemini-consecutive-roles.md
Normal file
1
changelog.d/fixes/claude-to-gemini-consecutive-roles.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(translator):** merge consecutive same-role contents in direct Claude to Gemini request translation to prevent upstream HTTP 400 errors
|
||||||
1
changelog.d/fixes/cline-task-id-passthrough.md
Normal file
1
changelog.d/fixes/cline-task-id-passthrough.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(cline):** Preserve client-supplied Cline task IDs and omit the header when clients provide none, preventing request-scoped proxy IDs from being reported as tasks.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(catalog):** derive combo reasoning-effort tiers from the exact runtime-selectable connection scope, intersecting dynamic, pinned, allowlisted, and compatible provider-node evidence while failing closed on unknown capabilities.
|
||||||
1
changelog.d/fixes/combo-sticky-pin-clear-on-disable.md
Normal file
1
changelog.d/fixes/combo-sticky-pin-clear-on-disable.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- fix(combo): evict in-memory session-stickiness bindings when a combo disables stickiness, so stale pins stop overriding the declared priority order until TTL/restart
|
||||||
1
changelog.d/fixes/minimax-music-generation-dispatch.md
Normal file
1
changelog.d/fixes/minimax-music-generation-dispatch.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
- **fix(sse):** MiniMax music models now generate audio instead of failing with `Unsupported music format: minimax-music` — the provider entry was registered in the music registry (and advertised by `/v1/models`), but `handleMusicGeneration` had no branch for its format, so every `minimax/*` music request fell through the dispatch chain to a 400. Adds the missing dispatch: a single synchronous POST with the `base_resp` envelope check (a non-zero `status_code` arrives on HTTP 200 too), `data.status` handling (an unfinished generation is reported instead of polled — the operation has no task id and no query endpoint), `url` and `hex` output formats (hex normalized to base64), `mp3`/`wav`/`pcm` containers via `audio_setting`, and the regional endpoint through the per-connection base-URL override, which is also the only host that accepts `aigc_watermark`. The registry entry gains the generation and cover model ids it was missing and drops a query URL that does not exist for this operation. Regression guard: `tests/unit/minimax-music-generation.test.ts` (9 tests).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **fix(models):** a model synced from a provider's own `/models` discovery is now enforced at its real context window immediately, instead of waiting up to 24h for the Feature 5004 reconciler's next tick. The request-time token-limit chain resolves the window from `auto:discovery` overrides, which previously were only written at startup and on a 24h interval — so any model synced mid-cycle (models.dev not indexing it yet, no static registry entry) fell through to the provider's static `defaultContextLength` (128K for OpenRouter) while `/v1/models` simultaneously advertised the real window from the same discovery data. Measured: `openrouter/stealth/ox-alpha` advertised `context_length: 1048576` but rejected requests over 128K with `context_length_exceeded` for a full day after its sync. The reconcile now also runs opportunistically (debounced, fire-and-forget) right after a synced catalog write changes. Companion fix: discovery now captures the vendor-declared `reasoning.default_effort` (e.g. OpenRouter `stealth/ox-alpha` declares `max`, normalized to `xhigh`) as `defaultThinkingEffort`, and the OpenAI dispatch path injects it when a request carries no reasoning field of any shape — the lowest-priority default behind a `-{effort}` suffix alias and a static `ModelSpec.defaultReasoningEffort` — so a reasoning model that returns an empty response without an explicit effort gets the vendor default instead of `upstream_empty_response`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
- **docs(docker):** spell out that `:latest` tracks the highest **published** stable SemVer (not git `main`), and that GitOps should pin `X.Y.Z` ([#10317](https://github.com/diegosouzapw/OmniRoute/issues/10317))
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user