mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
Compare commits
2 Commits
fix/10940-
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd15aef16 | ||
|
|
131aebcde5 |
122
.env.example
122
.env.example
@@ -128,11 +128,6 @@ PORT=20128
|
||||
# Optional: set the public origin *with* the same path so OAuth and display URLs
|
||||
# stay consistent without relying on window.location.origin alone:
|
||||
# NEXT_PUBLIC_BASE_URL=https://host/omniroute
|
||||
#
|
||||
# Explicit path probed by the container health check. Unset, the probe derives it
|
||||
# from OMNIROUTE_BASE_PATH; setting it opts back into the deep monitoring endpoint.
|
||||
# Used by: scripts/dev/healthcheck.mjs
|
||||
# OMNIROUTE_HEALTHCHECK_PATH=/api/monitoring/health
|
||||
|
||||
# Opt-in iframe embedding of the OmniRoute HTML pages (issue #10273). Off by default:
|
||||
# every route ships `frame-ancestors 'none'` + `X-Frame-Options: DENY`, which is why the
|
||||
@@ -143,8 +138,6 @@ PORT=20128
|
||||
# (/api, /v1, /v1beta, /a2a, /healthz and the root-level aliases) keeps the strict
|
||||
# headers regardless. Only `vscode` is recognised; `1`/`true` do NOT enable it.
|
||||
# Used by: next.config.mjs via scripts/build/dashboardEmbed.mjs — build-time, rebuild after changing.
|
||||
# Docker: pass it as a build arg (`docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode`);
|
||||
# setting it on an already-built server or image does nothing.
|
||||
# DASHBOARD_ALLOW_EMBED=vscode
|
||||
|
||||
# Split-port mode: serve Dashboard and API on separate ports for network isolation.
|
||||
@@ -229,15 +222,6 @@ PORT=20128
|
||||
# unaffected by this dev-only flag).
|
||||
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).
|
||||
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip.
|
||||
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
|
||||
@@ -273,11 +257,6 @@ OMNIROUTE_USE_TURBOPACK=1
|
||||
# so a missing/corrupt cache never breaks tab-completion.
|
||||
# OMNIROUTE_DEBUG_COMPLETION=1
|
||||
|
||||
# Set to 1 to print per-request timing diagnostics from the CLI quota commands
|
||||
# to stderr (`[omniroute] GET <path> completed in Nms`).
|
||||
# Used by: bin/cli/commands/quota.mjs
|
||||
# OMNIROUTE_DEBUG=1
|
||||
|
||||
# Docker production port mappings (docker-compose.prod.yml only).
|
||||
# These set the HOST-side published ports. Container ports use PORT/API_PORT.
|
||||
# PROD_DASHBOARD_PORT=20130
|
||||
@@ -417,15 +396,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# 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.
|
||||
# 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.
|
||||
# A short bounded wait serializes agent bursts instead of an instant 503; 0 = instant.
|
||||
# Default 2000 (2s).
|
||||
@@ -714,11 +684,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# ALL_PROXY=socks5://127.0.0.1:7890
|
||||
# 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.
|
||||
# Long-lived SSE streams such as Codex /v1/responses need more than one
|
||||
# connection when multiple requests share the same account-level proxy.
|
||||
@@ -796,25 +761,11 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# CLI_CURSOR_BIN=agent
|
||||
# CLI_CLINE_BIN=cline
|
||||
# CLI_CONTINUE_BIN=cn
|
||||
# CLI_QODER_BIN=qodercli
|
||||
# CLI_QODER_BIN=qoder
|
||||
# CLI_QWEN_BIN=qwen
|
||||
# CLI_AIDER_BIN=aider
|
||||
# CLI_GOOSE_BIN=goose
|
||||
# CLI_GEMINI_BIN=gemini
|
||||
# CLI_KILO_BIN=kilocode
|
||||
# CLI_OPENCODE_BIN=opencode
|
||||
# CLI_HERMES_BIN=hermes
|
||||
# CLI_FORGE_BIN=forge
|
||||
# CLI_JCODE_BIN=jcode
|
||||
# CLI_DEEPSEEK_TUI_BIN=deepseek-tui
|
||||
# CLI_CODEWHALE_BIN=codewhale
|
||||
# CLI_SMELT_BIN=smelt
|
||||
# CLI_PI_BIN=pi
|
||||
# CLI_CRUSH_BIN=crush
|
||||
# CLI_OMP_BIN=omp
|
||||
# CLI_LETTA_BIN=letta
|
||||
# Windsurf has no default binary — set this to enable binary detection for it.
|
||||
# CLI_WINDSURF_BIN=windsurf
|
||||
# CLI_AUGGIE_BIN=auggie
|
||||
# AUGGIE_BIN=auggie
|
||||
|
||||
@@ -899,21 +850,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Set to 0/false/off to skip compression entirely. Default: 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.
|
||||
# Used by: src/shared/services/modelSyncScheduler.ts — periodic model refresh.
|
||||
# Default: 24
|
||||
# MODEL_SYNC_INTERVAL_HOURS=24
|
||||
|
||||
# Provider limits sync interval in minutes (rate limit windows, quotas).
|
||||
# Used by: src/lib/usage/providerLimits.ts — polls provider health endpoints.
|
||||
# Used by: src/server-init.ts — polls provider health endpoints.
|
||||
# Default: 70
|
||||
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
|
||||
|
||||
@@ -1064,10 +1007,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: src/lib/db/core.ts::getDbHealthCheckIntervalMs().
|
||||
#OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS=21600000
|
||||
|
||||
# WAL truncate cadence override (ms). Set to 0 to disable. Default: 21600000 (6h).
|
||||
# Used by: src/lib/db/core.ts::getWalTruncateIntervalMs().
|
||||
#OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS=21600000
|
||||
|
||||
# Skip the Redis-backed auth cache used by API key lookups (forces DB reads).
|
||||
# Used by: src/lib/db/apiKeys.ts. Set to 1 to disable. Default: enabled.
|
||||
#OMNIROUTE_DISABLE_REDIS_AUTH_CACHE=0
|
||||
@@ -1383,14 +1322,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# FETCH_BODY_TIMEOUT_MS=600000 # Time to receive full response body
|
||||
# FETCH_CONNECT_TIMEOUT_MS=30000 # TCP connection establishment (default: 30s)
|
||||
# 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
|
||||
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
|
||||
@@ -1445,14 +1376,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
|
||||
# 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) ──
|
||||
# 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
|
||||
@@ -2026,16 +1949,6 @@ APP_LOG_TO_FILE=true
|
||||
# Reachability probe target for the scheduler and the auto-test endpoint.
|
||||
# Point it at an internal/self-hosted URL to avoid the public default.
|
||||
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
|
||||
# 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.
|
||||
# PROXY_AUTO_REMOVE=false
|
||||
# Consecutive failures before an auto-remove fires. Default: 3.
|
||||
@@ -2178,19 +2091,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: detect local install, else pin.
|
||||
# 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/).
|
||||
# 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.
|
||||
@@ -2917,14 +2817,18 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# Minimum spacing between submissions and the extra pause after every third success.
|
||||
# ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS=12000
|
||||
# ADOBE_FIREFLY_BATCH_EXTRA_GAP_MS=15000
|
||||
# Browser used by Adobe Firefly renewal. True headless is debug-only: Adobe
|
||||
# colligo normally rejects risk tokens minted without a headed browser.
|
||||
# Used by: open-sse/services/adobeFireflyBrowserLogin.ts
|
||||
# Chrome CDP runtime used by Adobe Firefly renewal. True headless is debug-only:
|
||||
# Adobe colligo normally rejects risk tokens minted without a headed browser.
|
||||
# ADOBE_FIREFLY_CHROME_CDP_PORT=9334
|
||||
# ADOBE_FIREFLY_CHROME_VISIBLE=0
|
||||
# ADOBE_FIREFLY_CHROME_HEADED=0 # Legacy alias for ADOBE_FIREFLY_CHROME_VISIBLE=1
|
||||
# ADOBE_FIREFLY_CHROME_HEADLESS=0
|
||||
# The CDP-attached Chrome runtime (adobeFireflyChromeRuntime.ts) was removed in
|
||||
# #9255 along with its knobs — ADOBE_FIREFLY_CHROME_CDP_PORT, _VISIBLE, _HEADED,
|
||||
# _PING, _FORCE_RESTART, ADOBE_FIREFLY_LOGIN_WAIT_MS and _FORTER_WAIT_MS are read
|
||||
# nowhere and have no effect.
|
||||
# ADOBE_FIREFLY_CHROME_FORCE_RESTART=0
|
||||
# ADOBE_FIREFLY_CHROME_PING=auto
|
||||
# ADOBE_FIREFLY_LOGIN_WAIT_MS=0
|
||||
# ADOBE_FIREFLY_FORTER_WAIT_MS=45000
|
||||
# Optional absolute Chrome executable; auto-detected when unset.
|
||||
# CHROME_PATH=
|
||||
|
||||
# Telegram Mini App bridge. The update endpoint remains disabled while the bot
|
||||
# token is unset. Used by: src/lib/telegram/* and src/app/api/telegram/update/route.ts.
|
||||
|
||||
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.
|
||||
- dependency-name: "ioredis"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# @huggingface/transformers is VPS-validated at ^4.2.0 (migrated intentionally in
|
||||
# #9962). It is load-bearing for the LLMLingua ONNX compression engine (open-sse/
|
||||
# services/compression/engines/llmlingua/ — @atjsh/llmlingua-2@2.0.5 peers on
|
||||
# "@huggingface/transformers": "^3.5.2 || ^4.0.0") and for local memory embeddings
|
||||
# (src/lib/memory/embedding/transformersLocal.ts). Further majors must be re-validated
|
||||
# on the VPS — so keep auto-bumps frozen (no update-types = ignore every version).
|
||||
# Migrate it intentionally, not via dependabot (#4050).
|
||||
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
|
||||
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
|
||||
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
|
||||
# and for local memory embeddings (src/lib/memory/embedding/transformersLocal.ts),
|
||||
# and was VPS-validated at 3.5.2 (#4014). 4.x breaks both, and even 3.x minors must
|
||||
# be re-validated on the VPS — so freeze ALL auto-bumps (no update-types = ignore
|
||||
# every version). Migrate it intentionally, not via dependabot (#4050).
|
||||
- dependency-name: "@huggingface/transformers"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,10 +22,10 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
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
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4.37.7
|
||||
uses: github/codeql-action/upload-sarif@v4.37.6
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
5
.github/workflows/electron-release.yml
vendored
5
.github/workflows/electron-release.yml
vendored
@@ -279,14 +279,9 @@ jobs:
|
||||
|
||||
- name: Smoke packaged Electron app (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:
|
||||
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
||||
ELECTRON_SMOKE_STREAM_LOGS: "1"
|
||||
ELECTRON_SMOKE_COLD_RESTART: "1"
|
||||
run: xvfb-run -a npm run electron:smoke:packaged
|
||||
|
||||
- name: Collect installers
|
||||
|
||||
64
.github/workflows/radar-export.yml
vendored
64
.github/workflows/radar-export.yml
vendored
@@ -1,64 +0,0 @@
|
||||
# Publica o export estável do catálogo consumido pelo OmniRoute Radar numa URL
|
||||
# fixa (asset de release `radar-export-latest`), para o servidor privado do Radar
|
||||
# (1 GB RAM, nunca clona/builda o OmniRoute) baixá-lo via `RADAR_EXPORT_URL` em
|
||||
# vez de depender do snapshot gravado no deploy. Fonte: scripts/release/radar-export.mjs.
|
||||
#
|
||||
# A URL estável resultante (definir em RADAR_EXPORT_URL no .env do radar-server):
|
||||
# https://github.com/diegosouzapw/OmniRoute/releases/download/radar-export-latest/export-omniroute.json
|
||||
name: Radar Export
|
||||
|
||||
on:
|
||||
workflow_dispatch: # o operador pode publicar sob demanda (de qualquer ref)
|
||||
push:
|
||||
branches: [main] # produção: só o catálogo do main clobra o asset estável
|
||||
paths:
|
||||
- open-sse/config/freeModelCatalog.data.ts
|
||||
- open-sse/config/freeModelCatalog.ts
|
||||
- open-sse/config/providerRegistry.ts
|
||||
- open-sse/config/providers/**
|
||||
- scripts/release/radar-export.mjs
|
||||
- .github/workflows/radar-export.yml
|
||||
schedule:
|
||||
- cron: "17 6 * * 1" # semanal (segunda 06:17 UTC): mantém geradoEm/proveniência frescos
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: radar-export-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CI_NODE_VERSION: "24"
|
||||
|
||||
jobs:
|
||||
publish-export:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # gh release upload — clobra o asset estável do export
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false # publish usa GH_TOKEN via gh release, não a credencial do checkout
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Generate catalog export with provenance
|
||||
run: node --import tsx/esm scripts/release/radar-export.mjs "$RUNNER_TEMP/export-omniroute.json"
|
||||
- name: Publish to the stable release asset
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="radar-export-latest"
|
||||
# Cria o release estável na primeira vez; nas seguintes só re-anexa o asset.
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "Radar catalog export (rolling)" \
|
||||
--notes "Export estável do catálogo OmniRoute para o Radar. Atualizado automaticamente; NÃO é um release de versão do produto." \
|
||||
--latest=false
|
||||
fi
|
||||
gh release upload "$TAG" "$RUNNER_TEMP/export-omniroute.json" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -291,4 +291,3 @@ docker-compose.yml.bak
|
||||
|
||||
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
|
||||
/.sandbox/
|
||||
.aider*
|
||||
|
||||
@@ -76,17 +76,6 @@ import {
|
||||
type FreeModelFreeType,
|
||||
} 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
|
||||
* `plugin: [name, opts]` tuple in opencode.json. Strict by design — unknown
|
||||
@@ -802,18 +791,13 @@ export async function forceSyncOmniRouteModels(args: {
|
||||
try {
|
||||
rawCombos = await combosFetcher(auth.baseURL, auth.managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
logger.warn("force sync: combos fetch failed", err);
|
||||
console.warn("[omniroute-plugin] force sync: combos fetch failed", err);
|
||||
}
|
||||
}
|
||||
let rawAutoCombos: OmniRouteRawAutoCombo[] = [];
|
||||
if (wantAutoCombos) {
|
||||
try {
|
||||
rawAutoCombos = await autoCombosFetcher(
|
||||
auth.baseURL,
|
||||
auth.managementReadToken,
|
||||
5_000,
|
||||
logger
|
||||
);
|
||||
rawAutoCombos = await autoCombosFetcher(auth.baseURL, auth.managementReadToken, 5_000);
|
||||
} catch {
|
||||
/* soft-fail */
|
||||
}
|
||||
@@ -1105,7 +1089,7 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
|
||||
|
||||
return {
|
||||
auth: createOmniRouteAuthHook(resolved),
|
||||
provider: createOmniRouteProviderHook(resolved, { cache: sharedCache, logger }),
|
||||
provider: createOmniRouteProviderHook(resolved, { cache: sharedCache }),
|
||||
config: configWithSyncCommand,
|
||||
tool: {
|
||||
omniroute_sync_models: syncTool,
|
||||
@@ -1310,15 +1294,10 @@ export function mapRawModelToModelV2(
|
||||
// `(providerID, modelID)`. If the raw id is already provider-prefixed
|
||||
// (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or
|
||||
// `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave
|
||||
// it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids
|
||||
// (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed:
|
||||
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under
|
||||
// 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}`,
|
||||
// it as-is — double-prefixing breaks OC's lookup. Otherwise prefix with
|
||||
// the resolved `providerId` so a bare key like `claude-opus-4` parses as
|
||||
// `(omniroute, claude-opus-4)` and the credentials resolve correctly.
|
||||
id: raw.id.includes("/") ? raw.id : `${ctx.providerId}/${raw.id}`,
|
||||
/**
|
||||
* Display name. Falls back to raw.id when no enrichment is available;
|
||||
* the caller (`createOmniRouteProviderHook`) overlays
|
||||
@@ -1692,8 +1671,7 @@ export interface OmniRouteRawAutoCombo {
|
||||
export type OmniRouteAutoCombosFetcher = (
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
timeoutMs?: number,
|
||||
logger?: OmniRouteLoggerSink
|
||||
timeoutMs?: number
|
||||
) => Promise<OmniRouteRawAutoCombo[]>;
|
||||
|
||||
/**
|
||||
@@ -1705,11 +1683,9 @@ export type OmniRouteAutoCombosFetcher = (
|
||||
export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = async (
|
||||
baseURL,
|
||||
apiKey,
|
||||
timeoutMs = 5_000,
|
||||
logger?: OmniRouteLoggerSink
|
||||
timeoutMs = 5_000
|
||||
) => {
|
||||
if (!apiKey || !baseURL) return [];
|
||||
const log = logger ?? _logger;
|
||||
|
||||
const trimmed = trimTrailingSlashes(baseURL);
|
||||
const root = trimmed.replace(/\/v\d+$/, "");
|
||||
@@ -1728,11 +1704,15 @@ export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = asy
|
||||
});
|
||||
// 404 = endpoint not deployed yet — expected during rollout
|
||||
if (res.status === 404) {
|
||||
log.warn(`/api/combos/auto not available (404) — auto combos disabled`);
|
||||
console.warn(
|
||||
`[omniroute-plugin] /api/combos/auto not available (404) — auto combos disabled`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
if (!res.ok) {
|
||||
log.warn(`/api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled`);
|
||||
console.warn(
|
||||
`[omniroute-plugin] /api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const body = (await res.json()) as unknown;
|
||||
@@ -1750,8 +1730,8 @@ export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = asy
|
||||
return out;
|
||||
} catch (err) {
|
||||
// Network error, timeout, abort — all non-fatal
|
||||
log.warn(
|
||||
`/api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled`
|
||||
console.warn(
|
||||
`[omniroute-plugin] /api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled`
|
||||
);
|
||||
return [];
|
||||
} finally {
|
||||
@@ -2950,7 +2930,10 @@ export function passesModelAllowlist(
|
||||
* filter is set, all combos pass. Combos with zero resolvable members pass
|
||||
* (mirrors `isUsableCombo` semantics).
|
||||
*/
|
||||
export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelListFilter): boolean {
|
||||
export function passesComboAllowlist(
|
||||
combo: OmniRouteRawCombo,
|
||||
visible?: ModelListFilter
|
||||
): boolean {
|
||||
if (!visible) return true;
|
||||
const steps = Array.isArray(combo.models) ? combo.models : [];
|
||||
if (steps.length === 0) return true;
|
||||
@@ -3142,15 +3125,9 @@ export function createOmniRouteProviderHook(
|
||||
providersFetcher?: OmniRouteProvidersFetcher;
|
||||
now?: () => number;
|
||||
cache?: OmniRouteFetchCache;
|
||||
logger?: _Logger;
|
||||
} = {}
|
||||
): ProviderHook {
|
||||
const resolved = resolveOmniRoutePluginOptions(opts);
|
||||
const logger =
|
||||
deps.logger ??
|
||||
createLogger(
|
||||
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
|
||||
);
|
||||
const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher;
|
||||
// T-05: combo discovery merges `/api/combos` entries into the same map as
|
||||
// `/v1/models`. Default fetcher is declared further down the file; the
|
||||
@@ -3224,8 +3201,8 @@ export function createOmniRouteProviderHook(
|
||||
: undefined) ??
|
||||
"";
|
||||
if (!baseURL) {
|
||||
logger.error(
|
||||
`provider.models(${resolved.providerId}): ` +
|
||||
console.warn(
|
||||
`[omniroute-plugin] provider.models(${resolved.providerId}): ` +
|
||||
`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.`
|
||||
);
|
||||
@@ -3256,8 +3233,8 @@ export function createOmniRouteProviderHook(
|
||||
rawModels = await fetcher(baseURL, apiKey, 10_000);
|
||||
|
||||
// T-05: combos fetch is best-effort, gated by features.combos.
|
||||
// Soft-fail on any error: emit a warn-level diagnostic and fall back
|
||||
// to a models-only catalog. Rationale: /api/combos requires a
|
||||
// Soft-fail on any error: emit a console.warn and fall back to a
|
||||
// models-only catalog. Rationale: /api/combos requires a
|
||||
// management-scoped key and OmniRoute may not have any combos
|
||||
// provisioned. Hard-failing when combos are optional would
|
||||
// silently hide the whole provider from OC's picker.
|
||||
@@ -3266,7 +3243,10 @@ export function createOmniRouteProviderHook(
|
||||
try {
|
||||
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
logger.warn("combos fetch failed, falling back to models-only catalog", err);
|
||||
console.warn(
|
||||
"[omniroute-plugin] combos fetch failed, falling back to models-only catalog",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3276,7 +3256,7 @@ export function createOmniRouteProviderHook(
|
||||
rawAutoCombos = [];
|
||||
if (wantAutoCombos) {
|
||||
try {
|
||||
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000, logger);
|
||||
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
|
||||
} catch {
|
||||
// Already handled inside the default fetcher — this catch
|
||||
// is belt-and-suspenders for injected stubs.
|
||||
@@ -3290,7 +3270,10 @@ export function createOmniRouteProviderHook(
|
||||
try {
|
||||
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
logger.warn("enrichment fetch failed, falling back to raw ids", err);
|
||||
console.warn(
|
||||
"[omniroute-plugin] enrichment fetch failed, falling back to raw ids",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3305,7 +3288,7 @@ export function createOmniRouteProviderHook(
|
||||
10_000
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn("compression-metadata fetch failed", err);
|
||||
console.warn("[omniroute-plugin] compression-metadata fetch failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3319,8 +3302,8 @@ export function createOmniRouteProviderHook(
|
||||
try {
|
||||
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"/api/providers fetch failed; usableOnly filter disabled for this refresh",
|
||||
console.warn(
|
||||
"[omniroute-plugin] /api/providers fetch failed; usableOnly filter disabled for this refresh",
|
||||
err
|
||||
);
|
||||
}
|
||||
@@ -3339,9 +3322,8 @@ export function createOmniRouteProviderHook(
|
||||
// Debug breadcrumb: surface fetch result so operators can confirm
|
||||
// the dynamic pipeline fired and how much catalog OmniRoute returned.
|
||||
// Emitted once per cache miss (TTL refresh) — quiet on cache hits.
|
||||
// Info-level: hidden at the default `warn` level (see #8982).
|
||||
logger.info(
|
||||
`catalog refreshed for providerId=${resolved.providerId} baseURL=${baseURL}: ` +
|
||||
console.warn(
|
||||
`[omniroute-plugin] catalog refreshed for providerId=${resolved.providerId} baseURL=${baseURL}: ` +
|
||||
`${rawModels.length} models + ${rawCombos.length} combos + ` +
|
||||
`${rawEnrichment.size} enrichment entries + ` +
|
||||
`${rawCompressionCombos.length} compression combos + ` +
|
||||
@@ -3621,7 +3603,9 @@ export function createOmniRouteProviderHook(
|
||||
const dedupeKey = `${cacheKey}::${comboKey}`;
|
||||
if (!collisionWarned.has(dedupeKey)) {
|
||||
collisionWarned.add(dedupeKey);
|
||||
logger.warn(`combo key "${comboKey}" collides with a model id; combo wins.`);
|
||||
console.warn(
|
||||
`[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3639,8 +3623,8 @@ export function createOmniRouteProviderHook(
|
||||
}
|
||||
|
||||
if (pending.length > 0) {
|
||||
logger.warn(
|
||||
`${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.`
|
||||
console.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.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4284,10 +4268,8 @@ export function buildStaticProviderEntry(
|
||||
enrichment?: OmniRouteEnrichmentMap,
|
||||
compressionCombos?: OmniRouteCompressionCombo[],
|
||||
connections?: OmniRouteProviderConnection[],
|
||||
rawAutoCombos?: OmniRouteRawAutoCombo[],
|
||||
logger?: OmniRouteLoggerSink
|
||||
rawAutoCombos?: OmniRouteRawAutoCombo[]
|
||||
): OmniRouteStaticProviderEntry {
|
||||
const log = logger ?? _logger;
|
||||
const models: Record<string, OmniRouteStaticModelEntry> = {};
|
||||
const rawModelKeys = new Set<string>();
|
||||
|
||||
@@ -4665,8 +4647,8 @@ export function buildStaticProviderEntry(
|
||||
}
|
||||
|
||||
if (pendingStatic.length > 0) {
|
||||
log.warn(
|
||||
`${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.`
|
||||
console.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.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4687,7 +4669,9 @@ export function buildStaticProviderEntry(
|
||||
const isExpectedRawTwin = autoCombo.id === key && rawModelKeys.has(key);
|
||||
if (!isExpectedRawTwin && !reportedCollisions.has(key)) {
|
||||
reportedCollisions.add(key);
|
||||
log.warn(`auto combo key "${key}" collides with an existing model; auto combo wins.`);
|
||||
console.warn(
|
||||
`[omniroute-plugin] auto combo key "${key}" collides with an existing model; auto combo wins.`
|
||||
);
|
||||
}
|
||||
}
|
||||
models[key] = entry;
|
||||
@@ -5358,8 +5342,7 @@ export function createOmniRouteConfigHook(
|
||||
warmSnapshot = snapshotResult;
|
||||
// Log snapshot age (accept any age — instant beats empty).
|
||||
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(
|
||||
"warn",
|
||||
`config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
|
||||
@@ -5411,12 +5394,7 @@ export function createOmniRouteConfigHook(
|
||||
const doAutoCombos = async (): Promise<void> => {
|
||||
if (!wantAutoCombos) return;
|
||||
try {
|
||||
localRawAutoCombos = await autoCombosFetcher(
|
||||
baseURL,
|
||||
managementReadToken,
|
||||
5_000,
|
||||
logger
|
||||
);
|
||||
localRawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
|
||||
} catch {
|
||||
// Already handled inside the default fetcher
|
||||
}
|
||||
@@ -5437,11 +5415,7 @@ export function createOmniRouteConfigHook(
|
||||
const doCompression = async (): Promise<void> => {
|
||||
if (!wantCompressionMeta) return;
|
||||
try {
|
||||
localRawCompressionCombos = await compressionMetaFetcher(
|
||||
baseURL,
|
||||
managementReadToken,
|
||||
10_000
|
||||
);
|
||||
localRawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
logAt(
|
||||
"error",
|
||||
@@ -5554,8 +5528,7 @@ export function createOmniRouteConfigHook(
|
||||
localRawEnrichment,
|
||||
localRawCompressionCombos,
|
||||
localRawConnections,
|
||||
localRawAutoCombos,
|
||||
logger
|
||||
localRawAutoCombos
|
||||
);
|
||||
const inputWithProvider2 = input as { provider?: Record<string, unknown> };
|
||||
if (inputWithProvider2.provider) {
|
||||
@@ -5645,8 +5618,7 @@ export function createOmniRouteConfigHook(
|
||||
rawEnrichment,
|
||||
rawCompressionCombos,
|
||||
rawConnections,
|
||||
rawAutoCombos,
|
||||
logger
|
||||
rawAutoCombos
|
||||
);
|
||||
|
||||
// Mutate the input.provider map. The Config type declares
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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,14 +5,8 @@ import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import type { Config } from "@opencode-ai/plugin";
|
||||
|
||||
import {
|
||||
createOmniRouteConfigHook,
|
||||
createOmniRouteProviderHook,
|
||||
defaultOmniRouteAutoCombosFetcher,
|
||||
OmniRoutePlugin,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/index.js";
|
||||
import { createLogger, getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
|
||||
import { createOmniRouteConfigHook, OmniRoutePlugin } from "../src/index.js";
|
||||
import { getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
|
||||
|
||||
type ConsoleMethod = "error" | "info" | "log" | "warn";
|
||||
type ConsoleEntries = Record<ConsoleMethod, unknown[][]>;
|
||||
@@ -222,105 +216,3 @@ test("logger error output remains visible at error level", async () => {
|
||||
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
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 346 LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 340 LLM providers, auto-fallback.
|
||||
|
||||
| Layer | Location | Purpose |
|
||||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
|
||||
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
||||
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (157 migrations) |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (153 migrations) |
|
||||
| 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 |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
|
||||
@@ -167,9 +167,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **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)
|
||||
- **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)
|
||||
- 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)
|
||||
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -47,21 +47,6 @@ rewrite it to the `_tasks/…` equivalent before writing:
|
||||
|
||||
Commit those artifacts inside the `_tasks/` repo (`git -C _tasks …`), never in the main repo.
|
||||
|
||||
## Scratch / temporary files — use `_artifacts/`, not `/tmp`
|
||||
|
||||
This project overrides the harness's default session scratchpad (`/tmp/claude-*/…`). Write
|
||||
temporary/working files — exports, generated zips, one-off intermediate outputs, anything you'd
|
||||
otherwise put in `/tmp` — to `/home/diegosouzapw/dev/proxys/OmniRoute/_artifacts/` instead.
|
||||
|
||||
- `_artifacts/` is a root `_*` path: already gitignored (`AGENTS.md` → "Root `_*` paths"), lives
|
||||
on disk only, never tracked.
|
||||
- Reason: keeping scratch output inside the project (vs `/tmp`) makes it trivial for the operator
|
||||
to find and delete everything temporary in one place, instead of hunting across ephemeral
|
||||
session-specific `/tmp` directories that vanish or accumulate untracked.
|
||||
- Do **not** confuse this with `_tasks/` (Hard Rule #23, its own private git repo for durable
|
||||
plans/specs/research/hand-offs) — `_artifacts/` is for disposable working files only, nothing
|
||||
here needs to survive or be versioned.
|
||||
|
||||
## Base-green before opening PRs
|
||||
|
||||
Before cutting a branch or opening a PR, run the base-green check (`AGENTS.md` → Git Workflow →
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -140,18 +140,6 @@ ENV OMNIROUTE_USE_TURBOPACK="${OMNIROUTE_USE_TURBOPACK}"
|
||||
ARG OMNIROUTE_BASE_PATH=""
|
||||
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
|
||||
|
||||
# #10273: the dashboard's `frame-ancestors` policy is compiled into the route
|
||||
# manifest by next.config.mjs (via scripts/build/dashboardEmbed.mjs), so it is
|
||||
# fixed when the image is built and cannot be flipped with `-e` on a running
|
||||
# container. Build with `--build-arg DASHBOARD_ALLOW_EMBED=vscode` to produce an
|
||||
# image whose HTML pages may be framed by the VS Code Simple Browser
|
||||
# (OmniCopilot's `dashboardOpen: "editor"`). Unset — the default — keeps every
|
||||
# route on `frame-ancestors 'none'` + X-Frame-Options: DENY. Builder-stage only:
|
||||
# the runner stage deliberately does not carry it, because a runtime value would
|
||||
# suggest an effect it cannot have.
|
||||
ARG DASHBOARD_ALLOW_EMBED=""
|
||||
ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
|
||||
|
||||
# Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert
|
||||
# access), so keep @/mitm/manager on the graceful stub (#3390). This flag is
|
||||
# Docker-only: npm/Electron/VPS builds must bundle the REAL manager (#6344).
|
||||
@@ -173,7 +161,7 @@ COPY . ./
|
||||
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data \
|
||||
&& 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', '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', '@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);"
|
||||
|
||||
# ── Runner base ────────────────────────────────────────────────────────────
|
||||
FROM base AS runner-base
|
||||
|
||||
26
README.md
26
README.md
@@ -7,7 +7,7 @@
|
||||
|
||||
# 🚀 OmniRoute — The Free AI Gateway
|
||||
|
||||
<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."/>
|
||||
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 340 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. 340 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
| | v3.8.49 | **v3.8.50** | `v3.8.51+` |
|
||||
| ------------------------- | :-----: | :---------: | :---------: |
|
||||
| 🌐 Providers | 290 | **342** | more queued |
|
||||
| 🌐 Providers | 290 | **340** | more queued |
|
||||
| 🧠 Documented models | 1185 | **1202** | — |
|
||||
| 🖼️ Modality Bridge | — | 🆕 vision | video |
|
||||
| 📡 Radar free catalog | — | 🆕 opt-in | — |
|
||||
@@ -101,7 +101,7 @@
|
||||
<tr>
|
||||
<td align="right"><b>⚙️ Features</b></td>
|
||||
<td align="center"><a href="#-combos--the-flagship">🎯 Combos</a></td>
|
||||
<td align="center"><a href="#-346-ai-providers--90-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-340-ai-providers--90-free">🌐 Providers</a></td>
|
||||
<td align="center"><a href="#-full-cli--a2a--mcp">🔌 CLI & MCP</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
</div>
|
||||
|
||||
<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)."/>
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint. 340 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 340 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)."/>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step:
|
||||
|
||||
</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: 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."/>
|
||||
<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: 340 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>
|
||||
|
||||
@@ -557,9 +557,9 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
- **🧠 Memory you control** — off by default, opt-in int8 vector quantization + typed decay, per-request `x-omniroute-no-memory`. → [Memory](docs/frameworks/MEMORY.md)
|
||||
- **🛡️ Security** — prompt-injection guard on every LLM route (red-team suite), opt-in credential-masking guardrail (redacts leaked API keys/secrets in both directions), free DuckDuckGo last-resort web search, and an optional OIDC login gate for the dashboard (password login always stays available). → [Guardrails](docs/security/GUARDRAILS.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, Google Imagen, 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)
|
||||
- **🤝 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)
|
||||
- **🤝 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 **340-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)
|
||||
- **⚡ 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">
|
||||
|
||||
## 🌐 346 AI Providers — 90+ Free
|
||||
## 🌐 340 AI Providers — 90+ Free
|
||||
|
||||
</div>
|
||||
|
||||
> The most complete catalog of any open-source router: **346 providers**, **90+ with a free tier**, **57 free forever**.
|
||||
> The most complete catalog of any open-source router: **340 providers**, **90+ with a free tier**, **56 free forever**.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -877,7 +877,7 @@ Engines run in pipeline order; each is independently toggleable and configurable
|
||||
<tr><td align="center" nowrap>9</td><td align="left" nowrap><b>Aggressive</b></td><td align="left">Summarization + progressive aging of old turns</td></tr>
|
||||
<tr><td align="center" nowrap>10</td><td align="left" nowrap><b>LLMLingua-2</b></td><td align="left">ML semantic pruning via MobileBERT ONNX — code-safe, async</td></tr>
|
||||
<tr><td align="center" nowrap>11</td><td align="left" nowrap><b>Ultra</b></td><td align="left">Heuristic token pruning with an optional small-model (SLM) tier</td></tr>
|
||||
<tr><td align="center" nowrap>12</td><td align="left" nowrap><b>OmniGlyph</b></td><td align="left">Experimental context-as-image encoding for measured Claude Fable 5 on the direct Anthropic wire; GPT 5.6 transformers remain fail-closed pending provider receipts. Four compression profiles (aggressive default, balanced, coding-safe, passthrough) (most aggressive; opt-in)</td></tr>
|
||||
<tr><td align="center" nowrap>12</td><td align="left" nowrap><b>OmniGlyph</b></td><td align="left">Experimental context-as-image encoding routed to Claude Fable 5 (most aggressive; opt-in)</td></tr>
|
||||
</table>
|
||||
|
||||
Code blocks, URLs and structured data are **always preserved** byte-perfect. **One-click presets** combine the engines:
|
||||
@@ -988,8 +988,6 @@ 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
|
||||
```
|
||||
|
||||
`: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
|
||||
> `diegosouzapw/omniroute:next-web` follow the current default `release/v*`
|
||||
> branch. These mutable tags are intended only for testing unreleased fixes and
|
||||
@@ -1174,7 +1172,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>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
|
||||
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 157 migrations</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 153 migrations</td></tr>
|
||||
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
|
||||
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
|
||||
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
|
||||
@@ -1497,7 +1495,7 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router]
|
||||
<table>
|
||||
<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/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/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/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/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>
|
||||
|
||||
@@ -52,19 +52,6 @@ function resolveUrl(path, opts) {
|
||||
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) {
|
||||
const headers = new Headers(opts.headers || {});
|
||||
if (!headers.has("accept")) headers.set("accept", "application/json");
|
||||
@@ -100,17 +87,10 @@ export async function buildHeaders(opts) {
|
||||
if (auth && !headers.has("authorization")) {
|
||||
headers.set("authorization", `Bearer ${auth}`);
|
||||
}
|
||||
// Inject the machine-derived credential only for an explicit local loopback
|
||||
// destination. Remote contexts and absolute remote URLs use scoped access
|
||||
// tokens and must never receive this machine-bound local credential.
|
||||
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);
|
||||
}
|
||||
// Inject machine-id derived CLI token; env var override for testing.
|
||||
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")) {
|
||||
headers.set("idempotency-key", opts.idempotencyKey);
|
||||
@@ -215,12 +195,8 @@ function fetchOnce(url, init, timeoutMs) {
|
||||
export async function apiFetch(path, opts = {}) {
|
||||
const method = String(opts.method || "GET").toUpperCase();
|
||||
const url = resolveUrl(path, opts);
|
||||
const headers = await buildHeaders({ ...opts, destinationUrl: url });
|
||||
const headers = await buildHeaders(opts);
|
||||
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 =
|
||||
opts.timeout ?? (Number.parseInt(process.env.OMNIROUTE_HTTP_TIMEOUT_MS || "", 10) || 30000);
|
||||
const maxAttempts = opts.retry === false ? 1 : (opts.retryMax ?? RETRY_DEFAULTS.maxAttempts);
|
||||
@@ -229,7 +205,7 @@ export async function apiFetch(path, opts = {}) {
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const res = await fetchOnce(url, { method, headers, body, redirect }, timeout);
|
||||
const res = await fetchOnce(url, { method, headers, body }, timeout);
|
||||
if (res.ok) return enrichResponse(res, opts);
|
||||
if (attempt < maxAttempts && shouldRetryStatus(res.status, method, opts)) {
|
||||
const delay = computeBackoff(attempt, res.headers.get("retry-after"));
|
||||
|
||||
@@ -4,9 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createDecipheriv, scryptSync } from "node:crypto";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { isLoopbackUrl } from "../api.mjs";
|
||||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||
import { getCliToken, CLI_TOKEN_HEADER } from "../utils/cliToken.mjs";
|
||||
import { printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs";
|
||||
@@ -380,11 +378,11 @@ function checkMemory() {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}) {
|
||||
async function fetchWithTimeout(url) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
return await fetch(url, { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
@@ -473,98 +471,6 @@ 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 = {}) {
|
||||
const rootDir =
|
||||
context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
@@ -582,7 +488,6 @@ export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
|
||||
if (!options.skipLiveness) {
|
||||
checks.push(await checkServerLiveness(options));
|
||||
checks.push(await checkMachineTokenAuth(options));
|
||||
}
|
||||
|
||||
// CLI tool health checks
|
||||
|
||||
@@ -129,34 +129,7 @@ function buildTestInput(connection, apiKey) {
|
||||
};
|
||||
}
|
||||
|
||||
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 } = {}) {
|
||||
async function runProviderTest(db, connection) {
|
||||
// Only API-key connections can be probed with a stored credential. OAuth /
|
||||
// no-auth connections have nothing for testProviderApiKey() to send, and
|
||||
// getProviderApiKey() throws for them by design — reporting that as a FAILED
|
||||
@@ -178,9 +151,6 @@ async function runProviderTest(db, connection, { serverUp = false } = {}) {
|
||||
// means the CLI has no probe recipe, not that the provider is unhealthy.
|
||||
// Persisting it would overwrite a good test_status with a failure.
|
||||
if (result.unsupported) {
|
||||
if (serverUp) {
|
||||
return testProviderConnectionThroughServer(connection);
|
||||
}
|
||||
return {
|
||||
connection: publicConnection(connection),
|
||||
...result,
|
||||
@@ -296,7 +266,6 @@ export async function runTestCommand(selector, opts = {}) {
|
||||
}
|
||||
|
||||
export async function runTestAllCommand(opts = {}) {
|
||||
const serverUp = await isServerUp();
|
||||
const { db } = await openOmniRouteDb();
|
||||
try {
|
||||
const connections = listProviderConnections(db);
|
||||
@@ -311,7 +280,7 @@ export async function runTestAllCommand(opts = {}) {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
results.push(await runProviderTest(db, connection, { serverUp }));
|
||||
results.push(await runProviderTest(db, connection));
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
|
||||
@@ -38,19 +38,12 @@ export async function runTestProviderCommand(provider, model, opts = {}) {
|
||||
}
|
||||
|
||||
const targetProvider = provider || "anthropic";
|
||||
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 targetModel = model || "claude-haiku-4-5-20251001";
|
||||
const repeat = opts.repeat && opts.repeat > 0 ? opts.repeat : 1;
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < repeat; i++) {
|
||||
const result = await _runSingleTest(connection, targetModel);
|
||||
const result = await _runSingleTest(targetProvider, targetModel);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
@@ -77,10 +70,18 @@ export async function runTestProviderCommand(provider, model, opts = {}) {
|
||||
}
|
||||
|
||||
async function _runAllProviders(opts) {
|
||||
const loaded = await _loadConnections();
|
||||
if (!loaded) return 1;
|
||||
const connections = loaded.filter(
|
||||
(c) => c.isActive !== false && (c.authType === "apikey" || c.testStatus !== "unavailable")
|
||||
const res = await apiFetch("/api/providers?limit=200", {
|
||||
retry: false,
|
||||
timeout: 5000,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
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) {
|
||||
console.log(t("test.noProviders"));
|
||||
@@ -88,7 +89,6 @@ async function _runAllProviders(opts) {
|
||||
}
|
||||
|
||||
const providers = connections.map((c) => ({
|
||||
connectionId: c.id,
|
||||
provider: c.provider ?? c.id,
|
||||
model: c.defaultModel ?? c.model,
|
||||
}));
|
||||
@@ -102,8 +102,8 @@ async function _runAllProviders(opts) {
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
providers.map(async ({ connectionId, provider, model }) => {
|
||||
const r = await _runSingleTest({ id: connectionId }, model);
|
||||
providers.map(async ({ provider, model }) => {
|
||||
const r = await _runSingleTest(provider, model);
|
||||
return { provider, model, ...r };
|
||||
})
|
||||
);
|
||||
@@ -123,13 +123,6 @@ async function _runAllProviders(opts) {
|
||||
|
||||
async function _runCompare(provider, opts) {
|
||||
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
|
||||
.split(",")
|
||||
.map((m) => m.trim())
|
||||
@@ -145,7 +138,7 @@ async function _runCompare(provider, opts) {
|
||||
for (const model of models) {
|
||||
const results = [];
|
||||
for (let i = 0; i < repeat; i++) {
|
||||
const result = await _runSingleTest(connection, model);
|
||||
const result = await _runSingleTest(targetProvider, model);
|
||||
results.push(result);
|
||||
}
|
||||
rows.push({ model, ..._aggregate(results, true) });
|
||||
@@ -187,55 +180,19 @@ async function _runCompare(provider, opts) {
|
||||
return rows.every((r) => r.success) ? 0 : 1;
|
||||
}
|
||||
|
||||
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) {
|
||||
async function _runSingleTest(provider, model) {
|
||||
const startMs = Date.now();
|
||||
try {
|
||||
const res = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}/test`, {
|
||||
const res = await apiFetch("/api/v1/providers/test", {
|
||||
method: "POST",
|
||||
body: model ? { validationModelId: model } : {},
|
||||
body: { provider, model },
|
||||
retry: false,
|
||||
timeout: 30000,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
const durationMs = Date.now() - startMs;
|
||||
const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` };
|
||||
return { ...data, success: data.valid === true, durationMs };
|
||||
const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` };
|
||||
return { ...data, durationMs };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
|
||||
@@ -118,11 +118,6 @@ export function npmInstallRuntime(pkgs, opts = {}) {
|
||||
// install of a sibling runtime dep (e.g. systray2 from trayRuntime.ts, which writes to the
|
||||
// same runtime dir) does not prune this package as "extraneous" — that pruning otherwise
|
||||
// reproduces "No SQLite driver available" after a tray install removes better-sqlite3.
|
||||
// npm 12+ defaults `allowScripts` to off, silently skipping lifecycle/install
|
||||
// scripts (e.g. better-sqlite3's node-gyp/prebuild-install rebuild) unless the
|
||||
// package has a matching `allowScripts` entry — and still exits 0, masking the
|
||||
// failure (#10713). The runtime dir is a CLI-owned, non-user package.json, so
|
||||
// explicitly allowing scripts for the packages we are installing here is safe.
|
||||
const npmArgs = [
|
||||
"install",
|
||||
...pkgs,
|
||||
@@ -130,7 +125,6 @@ export function npmInstallRuntime(pkgs, opts = {}) {
|
||||
"--no-fund",
|
||||
"--prefer-online",
|
||||
"--save-exact",
|
||||
...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
|
||||
];
|
||||
// On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly
|
||||
// so we never set shell:true (which would propagate env and enable injection).
|
||||
|
||||
@@ -121,16 +121,7 @@ function writeLinuxSystemdUnit(cliPath) {
|
||||
"Wants=network-online.target",
|
||||
"",
|
||||
"[Service]",
|
||||
// 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",
|
||||
"Type=simple",
|
||||
`ExecStart=${buildServeExecLine(cliPath, { tray: false })}`,
|
||||
"Restart=on-failure",
|
||||
"RestartSec=5",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { render, Box, Text, useInput } from "ink";
|
||||
import Spinner from "ink-spinner";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { DataTable } from "../tui-components/DataTable.jsx";
|
||||
import { ProgressBar } from "../tui-components/ProgressBar.jsx";
|
||||
|
||||
@@ -32,20 +31,22 @@ const TABLE_SCHEMA = [
|
||||
{ key: "error", header: "Error", width: 28, formatter: (v) => (v ? v.slice(0, 26) : "") },
|
||||
];
|
||||
|
||||
async function testOne(connectionId, model, baseUrl, apiKey) {
|
||||
async function testOne(provider, model, baseUrl, apiKey) {
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
};
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await apiFetch(`/api/providers/${encodeURIComponent(connectionId)}/test`, {
|
||||
const res = await fetch(`${baseUrl}/api/v1/providers/test`, {
|
||||
method: "POST",
|
||||
body: model ? { validationModelId: model } : {},
|
||||
baseUrl,
|
||||
token: apiKey,
|
||||
timeout: 30000,
|
||||
acceptNotOk: true,
|
||||
headers,
|
||||
body: JSON.stringify({ provider, model }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
const data = res.ok ? await res.json() : { valid: false, error: `HTTP ${res.status}` };
|
||||
return { status: data.valid ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error };
|
||||
const data = res.ok ? await res.json() : { success: false, error: `HTTP ${res.status}` };
|
||||
return { status: data.success ? STATUS.PASS : STATUS.FAIL, latencyMs, error: data.error };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
@@ -62,7 +63,6 @@ function ProvidersTestAllApp({ providers, baseUrl, apiKey, concurrency = 4, onEx
|
||||
const [rows, setRows] = useState(() =>
|
||||
providers.map((p, i) => ({
|
||||
id: i,
|
||||
connectionId: p.connectionId ?? p.id,
|
||||
provider: p.provider ?? p.id ?? String(p),
|
||||
model: p.model ?? p.defaultModel ?? "",
|
||||
status: STATUS.PENDING,
|
||||
@@ -91,7 +91,7 @@ function ProvidersTestAllApp({ providers, baseUrl, apiKey, concurrency = 4, onEx
|
||||
const row = queue[cursor++];
|
||||
running++;
|
||||
update(row.id, { status: STATUS.RUNNING });
|
||||
testOne(row.connectionId, row.model, resolved, apiKey).then((result) => {
|
||||
testOne(row.provider, row.model, resolved, apiKey).then((result) => {
|
||||
update(row.id, result);
|
||||
running--;
|
||||
nextSlot();
|
||||
|
||||
@@ -12,39 +12,25 @@ function getActiveSalt() {
|
||||
return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT;
|
||||
}
|
||||
|
||||
export function deriveCliToken(machineIdModule, salt) {
|
||||
export async function getCliToken() {
|
||||
const salt = getActiveSalt();
|
||||
if (_cached !== null && _cachedSalt === salt) return _cached;
|
||||
try {
|
||||
// node-machine-id is CommonJS: under `await import()` its exports land on
|
||||
// `.default`, so destructuring `machineIdSync` off the namespace yields
|
||||
// undefined and calling it throws — which the catch below turned into an
|
||||
// empty token, silently disabling CLI auth for every management request.
|
||||
// Same resolution order as src/lib/machineToken.ts.
|
||||
const machineIdSync =
|
||||
machineIdModule?.machineIdSync || machineIdModule?.default?.machineIdSync;
|
||||
if (typeof machineIdSync !== "function") return "";
|
||||
const mod = await import("node-machine-id");
|
||||
const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync;
|
||||
if (typeof machineIdSync !== "function") throw new Error("machine-id API unavailable");
|
||||
// machineIdSync(true) returns the original unhashed hardware ID — mirrors
|
||||
// getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening).
|
||||
const rawId = machineIdSync(true);
|
||||
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;
|
||||
const mid = machineIdSync(true);
|
||||
_cached = crypto.createHmac("sha256", mid).update(salt).digest("hex");
|
||||
} 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);
|
||||
_cached = "";
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function waitForServer(port, timeout = 60000) {
|
||||
// - "not-listening": nothing is accepting connections on the port at all.
|
||||
async function pollHealthOnce(port) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`, {
|
||||
const res = await fetch(`http://localhost:${port}/api/monitoring/health`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
return res.ok ? "ready" : "fast-reject";
|
||||
|
||||
@@ -119,9 +119,6 @@ function loadEnvFile() {
|
||||
addEnvPath(join(ROOT, ".env"));
|
||||
}
|
||||
|
||||
const keyOrigin = new Map();
|
||||
const shadowed = new Map();
|
||||
|
||||
for (const envPath of envPaths) {
|
||||
try {
|
||||
if (existsSync(envPath)) {
|
||||
@@ -134,31 +131,19 @@ function loadEnvFile() {
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
if (process.env[key] === undefined) {
|
||||
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);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` \x1b[33m⚠ Could not read ${envPath}: ${err?.message ?? err}\x1b[0m`);
|
||||
} catch {
|
||||
// Ignore errors reading env files.
|
||||
}
|
||||
}
|
||||
|
||||
for (const envPath of loadedEnvPaths) {
|
||||
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();
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- **feat(docker):** add `GET`/`HEAD` `/livez` as a process-alive probe, distinct from `/healthz` readiness ([#10316](https://github.com/diegosouzapw/OmniRoute/issues/10316))
|
||||
@@ -1 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- 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
|
||||
@@ -1,2 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- **docs(guides):** OmniRoute now serves VS Code's **native Copilot Chat model picker** through the [OmniCopilot](https://github.com/diegosouzapw/OmniCopilot) extension ([Marketplace](https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot) · [Open VSX](https://open-vsx.org/extension/diegosouzapw/omnicopilot) — Cursor, Windsurf, VSCodium, Theia…) — no Copilot subscription needed since VS Code 1.122. New [`docs/guides/VSCODE-COPILOT.md`](docs/guides/VSCODE-COPILOT.md) covers setup, how the picker collapses the `dual`-prefix catalog via `GET /v1/models?prefix=alias`, and the **build-time** `DASHBOARD_ALLOW_EMBED=vscode` flag that renders the dashboard in an editor tab ([#10697](https://github.com/diegosouzapw/OmniRoute/pull/10697))
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(docker):** `DASHBOARD_ALLOW_EMBED` is now a Docker build argument — `docker build --build-arg DASHBOARD_ALLOW_EMBED=vscode` produces an image whose dashboard renders inside the VS Code Simple Browser (OmniCopilot's `dashboardOpen: "editor"`). Previously the flag was only reachable from a source build: Docker silently drops a `--build-arg` with no matching `ARG`, so the operator got the default image and no error. Builder-stage only and empty by default — the runtime stages deliberately do not carry it, and the unframable default posture is unchanged ([#10701](https://github.com/diegosouzapw/OmniRoute/pull/10701))
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(providers):** new `cursor-api` provider (card "Cursor API", alias `cua`): connect a Cursor user API key (`crsr_…`) and route `cursor-api/<model>` through the existing Cursor agent executor (the key is exchanged for a 1h session token and cached), plus a `/api/cursor-cli/*` passthrough so the Cursor CLI itself runs through OmniRoute (`CURSOR_API_ENDPOINT=http://<omniroute>/api/cursor-cli`, `CURSOR_API_KEY=<OmniRoute key>`) with every RPC attributed and logged. The IDE `cursor` provider is unchanged. (#10729)
|
||||
@@ -1 +0,0 @@
|
||||
- **feat(api):** `GET /api/health` now answers `{ status, timestamp }` without a key. Until now the path had no route, so the management-auth boundary answered first with a 401 — indistinguishable from a wrong key or an unknown route, which left Docker HEALTHCHECKs and Kubernetes probes unable to tell "down" from "misconfigured". Kept deliberately minimal: version, uptime and memory stay behind the authenticated `/api/monitoring/health` ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10771)).
|
||||
@@ -1 +0,0 @@
|
||||
- feat(routing): make Task-Aware Smart Routing's detection patterns operator-configurable via `settings.taskRouting.patternOverrides` (`PUT /api/settings/task-routing`) — the built-in patterns are English-only, so a non-English dashboard had no recourse short of turning detection off entirely; an override now replaces the pattern list for one task type without touching the rest (#10783)
|
||||
@@ -1 +0,0 @@
|
||||
- feat(api): accept PATCH on /api/combos/[id], the verb the OpenAPI spec already documents (#10869)
|
||||
@@ -1 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- **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
|
||||
@@ -1 +0,0 @@
|
||||
- **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))
|
||||
@@ -1,8 +0,0 @@
|
||||
- `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 +0,0 @@
|
||||
- **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))
|
||||
@@ -1,2 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- 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 +0,0 @@
|
||||
- feat(sse): add Cursor plan image generation via Agent CLI (`IMAGE_PROVIDERS.cursor`, format `cursor-agent-image`), reusing the chat Cursor OAuth connection
|
||||
@@ -1 +0,0 @@
|
||||
- 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 +0,0 @@
|
||||
- **feat(usage):** show Kimi Coding's fixed-order Code 5-hour/7-day quota windows plus Extra Usage status, balance, monthly spend/limit, and the official Additional Credits link on Dashboard → Quota cards.
|
||||
@@ -1 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- feat(opencode-go): expose Muse Spark 1.2 Contributor reasoning-effort aliases (minimal/low/medium/high/xhigh) in the Combo Builder
|
||||
@@ -1 +0,0 @@
|
||||
- **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
|
||||
@@ -1 +0,0 @@
|
||||
- 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)
|
||||
@@ -1 +0,0 @@
|
||||
- **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).
|
||||
@@ -1 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- fix(video): stop advertising the googleflow (Veo) video provider as working and fail fast with a clear diagnostic — its submit/poll endpoints 404 and no server-side OAuth transport can satisfy the working endpoint (#10285)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(api): hash the API key before using it as the model-catalog cache Map key (no raw credentials in process heap) (#10313)
|
||||
@@ -1 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- **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))
|
||||
@@ -1 +0,0 @@
|
||||
- fix(sse): fail over combo streaming responses that reach `finish_reason` with zero content, reasoning, or tool_calls instead of forwarding a terminated-but-empty completion (#10404)
|
||||
@@ -1 +0,0 @@
|
||||
- **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
|
||||
@@ -1 +0,0 @@
|
||||
- fix(mitm): forward passthrough traffic to the actual requested Host instead of misrouting every non-TARGET_HOSTS request to the hardcoded Antigravity sandbox host (#10479)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(cli): use 127.0.0.1 for the readiness health-check poll instead of localhost, avoiding Windows DNS-resolution delays that made a healthy server report as never-ready (#10508)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): register a real Firefly auth probe under both the `firefly` alias and the `adobe-firefly` canonical id, and normalize the provider id before the generic web-cookie fallback, so a Firefly connection stops always reporting "Provider validation not supported" (#10522)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(sse): auto-replay a bounded multi-turn trajectory in the DeepSeek Web prompt builder for clients that never send `tools[]`, so agentic clients like Cline stop losing the original task after a couple of turns (#10527)
|
||||
@@ -1 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- 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)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(dashboard): show the real model count on the "List Models" endpoint card instead of a permanent "—" (#10553)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): remove 10 retired model ids from the crof seed catalog so /v1/models stops advertising models crof.ai no longer serves (#10577)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(sse): resolve the short provider-alias prefix (e.g. `el/`) advertised by GET /v1/models for audio speech, transcription and translation model ids (#10586)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(sse): map OpenAI-compat voice names to real ElevenLabs voice_ids in direct TTS (#10589)
|
||||
@@ -1 +0,0 @@
|
||||
- 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 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- fix(dashboard): make /api/models agree with /v1/models on synced-catalog coverage instead of reporting stale models as available (#10615)
|
||||
@@ -1 +0,0 @@
|
||||
- **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)).
|
||||
@@ -1 +0,0 @@
|
||||
- fix(guardrails): resolve the public provider alias before querying credentials in the Vision Bridge router, so command-code/opencode (and any alias!=id provider) are no longer reported as "unusable" despite active connections (#10702)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(dashboard): filter the Modality Bridge Vision model picker to vision-capable models, matching the sibling Video/Audio tabs (#10703)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(usage): repair provider-reported input_tokens: 0 on non-trivial requests instead of passing it through unrepaired (#10705)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(cli): distinguish a CLI-probe timeout from a genuinely absent binary in locateCommand, and resolve the Hermes Agent Apply flow's `keyId` server-side instead of writing the `YOUR_OMNIROUTE_API_KEY_HERE` placeholder (#10710, #10711)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(cli): pass --allow-scripts for the runtime's own npm-installed dependencies, so npm 12+'s default install-scripts block no longer silently skips better-sqlite3's native build (#10713)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(db): filter `getProviderMetrics()` to providers with a live `provider_connections` row so a deleted provider stops permanently ghost-haunting the Home "Provider Topology" widget (#10714)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(proxy): keep password-only proxy credentials instead of dropping them when no username is set (#10720)
|
||||
@@ -1 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- **fix(providers):** copilot-m365-web chat turns no longer surface as `(empty response)` — the type:4 invocation is aligned with the 2026-08 wire shape and now carries its type:1 Metrics follow-up in the same socket write, and the access token pre-flight-refreshes from a stored refresh_token instead of requiring a DevTools re-capture every ~75 minutes ([#10732](https://github.com/diegosouzapw/OmniRoute/pull/10732) — thanks @acc0mplish)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(catalog):** stop counting `getTokenLimit()`'s generic 128k catch-all as a known combo window, so `/v1/models` advertises the min of sourced member contexts instead of collapsing a 500k combo to 128k ([#10734](https://github.com/diegosouzapw/OmniRoute/issues/10734))
|
||||
@@ -1 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- fix(compression): skip the expensive `createCompressionStats()` pass in RTK when no message was actually compressed, matching every sibling stacked engine (#10765)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(api):** `/api/cache/stats` reported the prompt-cache LRU, which no request path ever writes to — it answered `0 hit / 0 miss, size 0` while the semantic cache served real traffic, and the Health and Usage dashboards rendered that as fact. It now reports the semantic cache's in-memory entries, with the same response shape ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10769)) — thanks @Poid-ZA, who first fixed this in #9446.
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(logging):** the app log is filterable and readable again. Entries from the tagged logger (`[LEVEL] [TAG] message`) were filed under the level instead of the component, and printf format strings were never applied, so `%s`/`%d` stayed literal with the values trailing behind them unlabelled — including every LiveWS connection line, where the format is deliberate hardening against injected format specifiers ([#PRNUM](https://github.com/diegosouzapw/OmniRoute/pull/10770)).
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(analytics):** Claude Code (`claude`/`cc`) is a flat-rate subscription, so cost analytics reports `$0` for it instead of estimating Anthropic list prices — the metered `anthropic` API keeps its real cost, and budget/quota/routing still estimate as before ([#10774](https://github.com/diegosouzapw/OmniRoute/pull/10774)) — thanks @electrumguy
|
||||
@@ -1 +0,0 @@
|
||||
- fix(db): periodically run `wal_checkpoint(TRUNCATE)` so the SQLite WAL file shrinks on long-running servers (default 6h, override with `OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS`, `0` disables) (#10781)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(sse): replace LiveWS's application-only liveness check with a protocol-level `ws.ping()`/`pong` heartbeat (RFC 6455 §5.5.2) alongside the existing one, so a read-only dashboard subscriber that never sends anything survives the connection timeout — a socket that stops reading frames entirely is still reaped exactly as before (#10782)
|
||||
@@ -1 +0,0 @@
|
||||
- **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)
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(resilience):** scope the same-account transport retry (#9708) out of emergency-fallback and combo hops — it was retrying the free fallback model and combo targets too, doubling upstream calls and corrupting the terminal error status on those paths.
|
||||
@@ -1 +0,0 @@
|
||||
- **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
|
||||
@@ -1 +0,0 @@
|
||||
- **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 +0,0 @@
|
||||
- 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 +0,0 @@
|
||||
- **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))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user