mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 14:52:22 +03:00
Compare commits
152 Commits
fix/12569-
...
fix/13232-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd058824b | ||
|
|
cde49c9372 | ||
|
|
a24562ece3 | ||
|
|
8f55d85d22 | ||
|
|
997cd4d509 | ||
|
|
c44f5da388 | ||
|
|
94d27e44fe | ||
|
|
13f44af6e5 | ||
|
|
e325888d78 | ||
|
|
d8c0448293 | ||
|
|
5cf4316b67 | ||
|
|
ca312d57aa | ||
|
|
6922332959 | ||
|
|
238cb1b076 | ||
|
|
29d66cbf8c | ||
|
|
df87e9363b | ||
|
|
87d9d82b37 | ||
|
|
08fe9e5117 | ||
|
|
931c9f9b9d | ||
|
|
53ed8c4745 | ||
|
|
defa0f07b2 | ||
|
|
611342609f | ||
|
|
ac52d4d9ea | ||
|
|
f0d2d34ee5 | ||
|
|
cad0fcc65d | ||
|
|
520428c8ad | ||
|
|
b283ed1830 | ||
|
|
45c54ca8aa | ||
|
|
c8b24ffc30 | ||
|
|
e7f9fec251 | ||
|
|
b97338a803 | ||
|
|
e498c349e3 | ||
|
|
104a34c5f2 | ||
|
|
62cd27720a | ||
|
|
7cabac4985 | ||
|
|
215ac43a70 | ||
|
|
1d17c239d1 | ||
|
|
cb420db64b | ||
|
|
831b485e08 | ||
|
|
516927196c | ||
|
|
3266d163f4 | ||
|
|
5a7a3d0121 | ||
|
|
4c0e45d814 | ||
|
|
c0e5b0a833 | ||
|
|
5395618aac | ||
|
|
ca9254ea65 | ||
|
|
5cc3362ef0 | ||
|
|
aaf0777a98 | ||
|
|
3af620e68f | ||
|
|
225da11fc6 | ||
|
|
ee3fbaf3f6 | ||
|
|
1d03ba0ed2 | ||
|
|
58f88a83e4 | ||
|
|
d36251cf1c | ||
|
|
9442bdef0f | ||
|
|
f782f3b1e0 | ||
|
|
5c1e35b98f | ||
|
|
c0f92ec98a | ||
|
|
3311ad20c6 | ||
|
|
4745fca27d | ||
|
|
2d1a281d12 | ||
|
|
3b5ce24acb | ||
|
|
56ae80a6bd | ||
|
|
1cbb77c30b | ||
|
|
30b5bf18fb | ||
|
|
93a398f353 | ||
|
|
6a55d0a170 | ||
|
|
cd112e34d3 | ||
|
|
d6e62ae394 | ||
|
|
0b358dbf64 | ||
|
|
a0d8ad7082 | ||
|
|
fb3f298489 | ||
|
|
918546acf2 | ||
|
|
d4a1470e40 | ||
|
|
56fedf985e | ||
|
|
c46ca1dc75 | ||
|
|
bf6658984b | ||
|
|
97ae10179c | ||
|
|
cce3a958b5 | ||
|
|
222fe4314e | ||
|
|
fc111dc196 | ||
|
|
1738beb0fe | ||
|
|
38ce44992e | ||
|
|
3a186c1326 | ||
|
|
76c928dd35 | ||
|
|
fdfa921bbb | ||
|
|
f938a08686 | ||
|
|
2a6d0586cd | ||
|
|
43b26615e1 | ||
|
|
04bea60303 | ||
|
|
65f513112a | ||
|
|
8bad85f58e | ||
|
|
67cb0ac96b | ||
|
|
5ca724d2cf | ||
|
|
895dda383e | ||
|
|
8786c1732f | ||
|
|
8f3621156d | ||
|
|
0d13ef4fbb | ||
|
|
963d3c46ef | ||
|
|
30bf6affe9 | ||
|
|
f7dbfc88c7 | ||
|
|
152d95108c | ||
|
|
cf2d29d2ad | ||
|
|
1b2dd3d282 | ||
|
|
7741aefbf6 | ||
|
|
db6feb6fad | ||
|
|
6dc66921a7 | ||
|
|
b97bc59f4f | ||
|
|
1fb7d5dff2 | ||
|
|
fb8f7d7fa2 | ||
|
|
95ebed770b | ||
|
|
01a66e26c6 | ||
|
|
84bc929dd8 | ||
|
|
f5e8c149cb | ||
|
|
0bdbe48de2 | ||
|
|
2acae48a5a | ||
|
|
8c5eff1bb4 | ||
|
|
bdc2fb76f6 | ||
|
|
2cb02d26c6 | ||
|
|
57d9e74881 | ||
|
|
1361a9dd88 | ||
|
|
0569f420be | ||
|
|
8751f111b2 | ||
|
|
02128f3343 | ||
|
|
c5707e65de | ||
|
|
13ee0f1e73 | ||
|
|
a5db197663 | ||
|
|
660137b3ce | ||
|
|
7a938fe39f | ||
|
|
39abab6681 | ||
|
|
2e872c50aa | ||
|
|
0431db3c62 | ||
|
|
cb55187eda | ||
|
|
b3bee580a8 | ||
|
|
b8b638e649 | ||
|
|
57a37ed858 | ||
|
|
d0b22ddf86 | ||
|
|
bc28bb06b1 | ||
|
|
6207868dc1 | ||
|
|
954f12b202 | ||
|
|
480b190fdc | ||
|
|
10099d037f | ||
|
|
d3a59621bf | ||
|
|
15b26879e6 | ||
|
|
8d52dcabe2 | ||
|
|
ced6ee72b3 | ||
|
|
907ffccd55 | ||
|
|
0b7a6abf48 | ||
|
|
c79caa913a | ||
|
|
9982e37e03 | ||
|
|
2ecb3d5c2d | ||
|
|
f1fc54eeb2 |
85
.env.example
85
.env.example
@@ -124,6 +124,21 @@ DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
# Host port for the compose Redis sidecar. Default: 6379.
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# Host interface docker-compose publishes the app's own ports (dashboard,
|
||||
# API, live-WS) on for the base/web/cli/host profiles and docker-compose.prod.yml.
|
||||
# Default: 127.0.0.1 (loopback only). Combined with REQUIRE_API_KEY=false
|
||||
# (the default below), an unqualified publish spec would expose the anonymous
|
||||
# /v1 LLM proxy to your whole LAN/WAN. Only set this to 0.0.0.0 once you've
|
||||
# confirmed REQUIRE_API_KEY=true, or that a reverse proxy in front of this
|
||||
# instance already enforces its own authentication. (#12568)
|
||||
# APP_BIND_HOST=127.0.0.1
|
||||
# Host interface docker-compose publishes the Qdrant memory sidecar on.
|
||||
# Default: 127.0.0.1 (loopback only). Same LAN-exposure reasoning as Redis.
|
||||
# QDRANT_BIND_HOST=127.0.0.1
|
||||
# Host interface docker-compose publishes the Bifrost router sidecar on.
|
||||
# Default: 127.0.0.1 (loopback only). Same LAN-exposure reasoning as Redis.
|
||||
# BIFROST_BIND_HOST=127.0.0.1
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 3. NETWORK & PORTS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -373,6 +388,8 @@ AUTH_COOKIE_SECURE=false
|
||||
# Require an API key for all /v1/* proxy endpoints.
|
||||
# Used by: API middleware — rejects unauthenticated requests to the proxy API.
|
||||
# Default: false | Set true for multi-user/public deployments.
|
||||
# Leaving this false is only safe when the app is reachable on loopback only
|
||||
# (see APP_BIND_HOST above) or sits behind a reverse proxy doing its own auth.
|
||||
REQUIRE_API_KEY=false
|
||||
|
||||
# Allow revealing full API key values in the Dashboard UI.
|
||||
@@ -720,6 +737,12 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
ENABLE_SOCKS5_PROXY=true
|
||||
NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
|
||||
# Opt-in feature flag (default off; a dashboard DB override wins over this value): proxy pools
|
||||
# and per-account rotation stop re-serving a member that just failed (TCP probe refused, or a
|
||||
# 429 received through it) for a period that doubles on each repeat, up to a cap. No proxy
|
||||
# status is written. "true" (or 1, yes) enables it; unset keeps plain selection.
|
||||
# PROXY_SKIP_RECENTLY_FAILED=false
|
||||
|
||||
# Standard proxy variables (lowercase variants also supported).
|
||||
# HTTP_PROXY=http://127.0.0.1:7890
|
||||
# HTTPS_PROXY=http://127.0.0.1:7890
|
||||
@@ -1123,6 +1146,22 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: src/lib/db/core.ts::getWalTruncateIntervalMs().
|
||||
#OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS=21600000
|
||||
|
||||
# Frequent wal_checkpoint(PASSIVE) cadence (ms). Set to 0 to disable. Default: 300000 (5m).
|
||||
# Used by: src/lib/db/walMaintenance.ts.
|
||||
#OMNIROUTE_WAL_PASSIVE_INTERVAL_MS=300000
|
||||
|
||||
# WAL size (MB) above which a PASSIVE tick escalates to wal_checkpoint(TRUNCATE). Default: 256.
|
||||
# Used by: src/lib/db/walMaintenance.ts.
|
||||
#OMNIROUTE_WAL_GUARD_MAX_MB=256
|
||||
|
||||
# Minimum rows a cleanup must delete before the post-cleanup VACUUM runs. Default: 1000.
|
||||
# 0 always vacuums when rows were freed. Used by: src/lib/db/cleanup.ts.
|
||||
#OMNIROUTE_VACUUM_MIN_DELETED_ROWS=1000
|
||||
|
||||
# Explicit path to sql-wasm.wasm for the sql.js fallback adapter. Default: auto-detect.
|
||||
# Used by: src/lib/db/adapters/sqljsAdapter.ts.
|
||||
#OMNIROUTE_SQLJS_WASM_PATH=
|
||||
|
||||
# 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
|
||||
@@ -1175,6 +1214,11 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
|
||||
# Trae OAuth token override. Used by: open-sse/executors/trae.ts.
|
||||
# TRAE_TOKEN=
|
||||
|
||||
# Trae web client Origin/Referer override (fleet-wide bump if Trae moves hosts
|
||||
# again without a code change). Default: https://work.trae.ai.
|
||||
# Used by: open-sse/executors/trae.ts.
|
||||
# TRAE_WEB_ORIGIN=https://work.trae.ai
|
||||
|
||||
# ── Gemini / Antigravity (Google-based) ──
|
||||
# These providers ship public OAuth client_id/secret values embedded in their
|
||||
# public CLIs. Defaults are baked into the code via
|
||||
@@ -1647,6 +1691,9 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
|
||||
# ── TLS client (wreq-js fingerprint proxy) ──
|
||||
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
|
||||
# TLS_FIRST_BYTE_WATCHDOG_MS=10000 # #12656: bounds time-to-first-byte on the wreq body (0 disables)
|
||||
# OPENCODE_RESPONSES_STALL_ROTATION=false # #13484 feature flag (Settings → Feature Flags wins): rotate once when a streamed Responses reply stalls before its first byte
|
||||
# RESPONSES_FIRST_BYTE_TIMEOUT_MS=15000 # #13484: OpenCode Responses first-byte window, only used when the OPENCODE_RESPONSES_STALL_ROTATION flag is on (0 disables)
|
||||
|
||||
# ── API Bridge (/v1 proxy server) ──
|
||||
# API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min)
|
||||
@@ -1777,6 +1824,13 @@ APP_LOG_TO_FILE=true
|
||||
# Override only to hand-tune for a known workload.
|
||||
# HEAP_PRESSURE_THRESHOLD_MB=
|
||||
|
||||
# Exit the process after critical resource pressure persists, so a supervisor
|
||||
# (systemd Restart=always, Docker restart policy) brings back a clean process.
|
||||
# Accepts 1/true/yes/on. Default: false. Used by: open-sse/utils/resourcePressure.ts.
|
||||
# OMNIROUTE_PRESSURE_SELF_RESTART=false
|
||||
# How long (ms) critical pressure must persist before that exit fires. Default: 120000 (2m).
|
||||
# OMNIROUTE_PRESSURE_SELF_RESTART_AFTER_MS=120000
|
||||
|
||||
# ── CLI helpers (bin/cli/) ──
|
||||
# Override UI language for CLI output. Accepts BCP-47 locale (e.g. en, pt-BR).
|
||||
# Falls back to LC_ALL / LC_MESSAGES / LANG / en if unset.
|
||||
@@ -1794,6 +1848,10 @@ APP_LOG_TO_FILE=true
|
||||
# Per-attempt HTTP timeout for CLI → server calls (milliseconds). Default: 30000.
|
||||
# OMNIROUTE_HTTP_TIMEOUT_MS=30000
|
||||
|
||||
# How long `omniroute serve` waits for the health endpoint before printing the
|
||||
# readiness-timeout warning (milliseconds). Also --ready-timeout. Default: 60000.
|
||||
# OMNIROUTE_READY_TIMEOUT_MS=60000
|
||||
|
||||
# Set to 1 to print retry/backoff details to stderr during CLI commands.
|
||||
# OMNIROUTE_VERBOSE=0
|
||||
|
||||
@@ -1939,6 +1997,12 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 8000 (8 seconds). On timeout, a last-good 200 is served when available.
|
||||
# CATALOG_BUILD_TIMEOUT_MS=8000
|
||||
|
||||
# Age after which a connection's synced model list stops being authoritative for routing (#12849).
|
||||
# A stale (or never-timestamped) synced catalog fails open to the provider registry.
|
||||
# Used by: src/lib/db/models/activeSyncedCatalog.ts
|
||||
# Default: 2592000000 (30 days)
|
||||
# OMNIROUTE_SYNCED_CATALOG_STALE_AFTER_MS=2592000000
|
||||
|
||||
# ── NanoBanana (Image Generation) ──
|
||||
# Polling config for async image generation jobs.
|
||||
# Used by: open-sse/handlers/imageGeneration.ts
|
||||
@@ -2078,6 +2142,13 @@ APP_LOG_TO_FILE=true
|
||||
# Management key for an externally managed instance. Embedded instances use
|
||||
# OmniRoute's encrypted service key.
|
||||
# CLIPROXYAPI_MANAGEMENT_KEY=
|
||||
# Host interface docker-compose publishes the cliproxyapi sidecar on (the
|
||||
# --profile cliproxyapi Docker service, port 8317). Default: 127.0.0.1
|
||||
# (loopback only) — its data volume holds provider OAuth/API credentials, and
|
||||
# the pinned image has no env-based data-plane api-keys override (only a
|
||||
# mounted config.yaml), so an unqualified publish spec would put a
|
||||
# credential-bearing service on your whole LAN. (#12578)
|
||||
# CLIPROXY_BIND_HOST=127.0.0.1
|
||||
|
||||
# ── Mux embedded service ──
|
||||
# Override the port where the embedded Mux (coder/mux) agent-orchestration
|
||||
@@ -2168,15 +2239,20 @@ APP_LOG_TO_FILE=true
|
||||
# proxy — only the operator sets active/inactive (a flaky probe must not strand an
|
||||
# assigned proxy; #6246). Set "true" to restore the legacy test-and-set behaviour.
|
||||
# PROXY_HEALTH_AUTO_DEACTIVATE=false
|
||||
# Opt-in feature flag (default off; a dashboard DB override wins over this value): show,
|
||||
# under a proxy pool in the dashboard, how many observed egress IPs served its members over
|
||||
# the last 24 h and how many connections used them (read-only, computed from the proxy log,
|
||||
# never used for routing). "true" (or 1, yes) enables it.
|
||||
# PROXY_POOL_EGRESS_OBSERVATION=false
|
||||
|
||||
# Allow OAuth and provider validation flows to bypass a pinned proxy and connect
|
||||
# directly when proxy reachability pre-checks fail. Default: false.
|
||||
# Also configurable from Dashboard > Settings > Feature Flags.
|
||||
# OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK=false
|
||||
|
||||
# Rate limit maximum wait time before failing a request (ms). Default: 15000 (15s)
|
||||
# Rate limit maximum wait time before failing a request (ms). Default: 30000 (30s)
|
||||
# Used by: open-sse/services/rateLimitManager.ts
|
||||
# RATE_LIMIT_MAX_WAIT_MS=15000
|
||||
# RATE_LIMIT_MAX_WAIT_MS=30000
|
||||
|
||||
# Limiter-managed execution backstop (Bottleneck `expiration`): bounds a job's
|
||||
# post-dispatch execution, never queue wait. Must stay ABOVE upstream
|
||||
@@ -2722,6 +2798,10 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
|
||||
# MEMORY_VEC_TOP_K=20 # default top-K for vector search
|
||||
# MEMORY_RRF_K=60 # RRF k constant (sqlite-vec hybrid recipe)
|
||||
# HF_HUB_ENDPOINT=https://huggingface.co # override Hugging Face Hub base URL for static potion downloads
|
||||
# Test/diagnostic seam (src/lib/memory/vectorStore.ts) — forces getVectorStore() to
|
||||
# return null (simulates a cloud/WASM environment without sqlite-vec), degrading
|
||||
# memory retrieval to FTS5 keyword search. Default off; leave unset in production.
|
||||
# VECTOR_STORE_DISABLE_VEC=false
|
||||
# TV6 typed memory decay (OPT-IN, default off — the sweep DELETES decayed memories)
|
||||
# MEMORY_TYPED_DECAY_ENABLED=false # master switch for the destructive sweep (default off)
|
||||
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
|
||||
@@ -2991,6 +3071,7 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# OMNIROUTE_VNC_READY_MS=45000
|
||||
# OMNIROUTE_VNC_HARVEST_MS=20000
|
||||
# OMNIROUTE_VNC_CHROMIUM_ARGS=--remote-debugging-port=9222 --no-first-run --no-default-browser-check
|
||||
# OMNIROUTE_VNC_NETWORK=omniroute-vnc-browser-login
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Data-dir alias (optional — open-sse/services/notionThreadSessions.ts)
|
||||
|
||||
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -144,6 +144,12 @@ jobs:
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:radar-sentinels
|
||||
- run: npm run check:tracked-artifacts
|
||||
# A test parked in vitest.config.ts's exclude list does not run, and looks like
|
||||
# coverage to whoever reads the tree. 62 files accumulated behind a comment pointing
|
||||
# at #8618 — closed in August while the list grew to 62; 51 of them passed when
|
||||
# finally measured (#13204). This gate requires every exclusion to name a tracker and
|
||||
# to appear in config/quality/vitest-exclusions.json, so the debt stays reviewable.
|
||||
- run: npm run check:vitest-exclusions
|
||||
# (gap 30) Also lives in quality.yml's PR-only "Merge integrity" job — because the
|
||||
# CHANGELOG half of that job needs a base to diff against. This half does NOT: the
|
||||
# generator either reproduces the committed SKILL.md files or it does not.
|
||||
@@ -505,9 +511,11 @@ jobs:
|
||||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
||||
# Real-translation ratchet: a leaf copied verbatim from en.json passes key
|
||||
# parity above but is still English to the user (es shipped 55% English).
|
||||
# Advisory in PR-0; flipped to blocking once the backlog is retranslated (PR-4).
|
||||
- name: i18n real-translation ratio (advisory)
|
||||
run: node scripts/i18n/check-translation-ratio.mjs --warn
|
||||
# Blocking since PR-4 retranslated the verbatim-English backlog: the share of
|
||||
# untranslated leaves per locale may only fall (ratchet baseline in
|
||||
# config/quality/i18n-translation-baseline.json; `npm run i18n:check-ratio:update`).
|
||||
- name: i18n real-translation ratio
|
||||
run: node scripts/i18n/check-translation-ratio.mjs
|
||||
# #8463: a rewritten English value used to leave its 39 translations behind
|
||||
# silently (googleOAuthWarning shipped wrong copy in 39 locales for months).
|
||||
# Key parity above cannot see it — a stale translation counts as covered.
|
||||
@@ -515,6 +523,16 @@ jobs:
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
|
||||
run: node scripts/i18n/check-ui-value-drift.mjs
|
||||
# Sibling of the drift gate above. That one catches an English value that was
|
||||
# REWRITTEN; this one catches an English key that was ADDED while some locales never
|
||||
# got it. The coverage gate at the top of this job cannot: it is a percentage per
|
||||
# locale, and 11 absent keys out of ~13,000 leaves coverage at 99.9%. Incident: the
|
||||
# Phase 3 canvas keys were translated across the 42 locales that existed, then the EU
|
||||
# batch (#13044) took the repo to 51 and the nine newcomers shipped untranslated.
|
||||
- name: i18n new-key coverage (a new key must reach every locale)
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
|
||||
run: node scripts/i18n/check-new-key-coverage.mjs
|
||||
|
||||
# #8038: cheap glossary/protected-terms consistency gate —
|
||||
# complements i18n-ui-coverage (key parity) and the ICU `i18n` job below
|
||||
|
||||
10
.github/workflows/radar-export.yml
vendored
10
.github/workflows/radar-export.yml
vendored
@@ -10,7 +10,11 @@ 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
|
||||
# `main` e a release ativa (default branch) publicam no mesmo asset estável: o
|
||||
# radar-server só consome o asset, então um merge de catálogo na release que ficasse
|
||||
# à espera do cron semanal deixava o feed até 7 dias atrás do README (2026-09-14: a
|
||||
# linha da Together removida em d6e62ae só saiu do feed com dispatch manual).
|
||||
branches: [main, "release/**"]
|
||||
paths:
|
||||
- open-sse/config/freeModelCatalog.data.ts
|
||||
- open-sse/config/freeModelCatalog.ts
|
||||
@@ -19,7 +23,9 @@ on:
|
||||
- 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
|
||||
# Diário 03:17 UTC — antes do `radar-feed.timer` do servidor (04:23 UTC), para o ciclo
|
||||
# do dia já enxergar o export do dia; também mantém geradoEm/proveniência frescos.
|
||||
- cron: "17 3 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -97,4 +97,11 @@
|
||||
# credential; the generic-api-key rule flags the long hyphenated string.
|
||||
'''omniroute-cheaperinference-sponsor-banner-dismissed-v\d+''',
|
||||
'''SunbreakWebUI1''',
|
||||
# Uzbek dashboard catalog (#13727, src/i18n/messages/uz.json `outputTokenDesc`):
|
||||
# "Yakunlash/javob tokenlari" = "completion/response tokens". The rule reads the
|
||||
# `...TokenDesc` key as a token assignment and the translated words as its value.
|
||||
'''Yakunlash/javob''',
|
||||
# Feature-flag id from #13439 (src/shared/constants/featureFlagDefinitions.ts):
|
||||
# `key: "PROTECTED_PRIORITY_INFRA_502_ENABLED"` is a flag name, not a credential.
|
||||
'''PROTECTED_PRIORITY_INFRA_502_ENABLED''',
|
||||
]
|
||||
|
||||
6356
.i18n-state.json
6356
.i18n-state.json
File diff suppressed because it is too large
Load Diff
@@ -56,8 +56,14 @@ explicitly:
|
||||
}
|
||||
```
|
||||
|
||||
The token can also come from the `OMNIROUTE_MANAGEMENT_API_KEY` environment
|
||||
variable (the option wins when both are set). Resolution order:
|
||||
`managementReadToken` option, then `OMNIROUTE_MANAGEMENT_API_KEY`, then the
|
||||
`apiKey` fallback.
|
||||
|
||||
Left unset, `managementReadToken` falls back to `apiKey` for backwards
|
||||
compatibility. When a gateway rejects that fallback, the catalog still
|
||||
compatibility, and the plugin warns once at startup that the fallback is
|
||||
active. When a gateway rejects that fallback, the catalog still
|
||||
publishes — but with raw model ids instead of display names, no canonical
|
||||
alias dedupe, no pricing and no combos. The plugin warns once per endpoint
|
||||
when this happens, naming the endpoint and the consequence, so the degraded
|
||||
@@ -65,25 +71,25 @@ catalog is never a mystery.
|
||||
|
||||
## Options
|
||||
|
||||
| Key | Default | Notes |
|
||||
| -------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `<providerId>/…` |
|
||||
| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) |
|
||||
| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) |
|
||||
| `managementReadToken` | falls back to `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key |
|
||||
| `displayName` | `"OmniRoute"` | Provider display name |
|
||||
| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) |
|
||||
| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts |
|
||||
| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` |
|
||||
| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) |
|
||||
| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to |
|
||||
| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) |
|
||||
| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) |
|
||||
| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins |
|
||||
| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block |
|
||||
| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic |
|
||||
| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` |
|
||||
| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity |
|
||||
| Key | Default | Notes |
|
||||
| -------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `<providerId>/…` |
|
||||
| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) |
|
||||
| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) |
|
||||
| `managementReadToken` | option, then `OMNIROUTE_MANAGEMENT_API_KEY`, then `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key |
|
||||
| `displayName` | `"OmniRoute"` | Provider display name |
|
||||
| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) |
|
||||
| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts |
|
||||
| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` |
|
||||
| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) |
|
||||
| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to |
|
||||
| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) |
|
||||
| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) |
|
||||
| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins |
|
||||
| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block |
|
||||
| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic |
|
||||
| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` |
|
||||
| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity |
|
||||
|
||||
## Tool calling on Gemini models
|
||||
|
||||
|
||||
484
@omniroute/opencode-plugin-v2/package-lock.json
generated
484
@omniroute/opencode-plugin-v2/package-lock.json
generated
@@ -1790,490 +1790,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsup/node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.3",
|
||||
"dev": true,
|
||||
|
||||
@@ -63,5 +63,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": ">=1.18.29 <2"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type {
|
||||
OmniRouteEnrichmentEntry,
|
||||
@@ -81,6 +81,12 @@ interface DiskSnapshotV2 {
|
||||
*/
|
||||
const MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
// Suffix for the temp file each write publishes via rename. Monotone per
|
||||
// process: two writes for one provider (for example across a credential
|
||||
// rotation) must not share a temp name. Built after the empty-models and
|
||||
// size-cap guards, so only real attempts consume a value.
|
||||
let snapshotWriteCounter = 0;
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
let i = value.length;
|
||||
while (i > 0 && value.charCodeAt(i - 1) === 0x2f) i -= 1;
|
||||
@@ -133,7 +139,7 @@ export async function readDiskSnapshot(
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed.v !== "number" ||
|
||||
parsed.v < SNAPSHOT_FORMAT_VERSION ||
|
||||
parsed.v !== SNAPSHOT_FORMAT_VERSION ||
|
||||
typeof parsed.identityFingerprint !== "string" ||
|
||||
parsed.identityFingerprint !== identityFingerprint
|
||||
) {
|
||||
@@ -179,8 +185,14 @@ export async function readDiskSnapshot(
|
||||
export async function writeDiskSnapshot(
|
||||
providerId: string,
|
||||
snapshot: CatalogSnapshot,
|
||||
identityFingerprint: string
|
||||
identityFingerprint: string,
|
||||
logger?: { warn: (message: string) => void }
|
||||
): Promise<void> {
|
||||
// Monotone per-process suffix: two writes for one provider (for example
|
||||
// across a credential rotation) must not share a temp name. Declared here
|
||||
// so the catch below can clean it up; assigned after the guards so only
|
||||
// real attempts consume a counter value.
|
||||
let tmp = "";
|
||||
try {
|
||||
if (snapshot.models.length === 0) return;
|
||||
const file = diskSnapshotPath(providerId);
|
||||
@@ -196,14 +208,33 @@ export async function writeDiskSnapshot(
|
||||
writtenAt: Date.now(),
|
||||
};
|
||||
let payload = JSON.stringify(envelope);
|
||||
if (payload.length > MAX_SNAPSHOT_BYTES && envelope.enrichment !== undefined) {
|
||||
if (
|
||||
Buffer.byteLength(payload, "utf8") > MAX_SNAPSHOT_BYTES &&
|
||||
envelope.enrichment !== undefined
|
||||
) {
|
||||
delete envelope.enrichment;
|
||||
payload = JSON.stringify(envelope);
|
||||
}
|
||||
if (payload.length > MAX_SNAPSHOT_BYTES) return;
|
||||
await writeFile(file, payload, { encoding: "utf8", mode: 0o600 });
|
||||
} catch {
|
||||
if (Buffer.byteLength(payload, "utf8") > MAX_SNAPSHOT_BYTES) {
|
||||
logger?.warn(
|
||||
`[omniroute-v2] snapshot for ${providerId} exceeds the size cap, skipping disk write`
|
||||
);
|
||||
return;
|
||||
}
|
||||
tmp = `${file}.${process.pid}.${snapshotWriteCounter++}`;
|
||||
await writeFile(tmp, payload, { encoding: "utf8", mode: 0o600 });
|
||||
await rename(tmp, file);
|
||||
} catch (err) {
|
||||
// Best-effort: callers already hold the in-memory entry.
|
||||
logger?.warn(
|
||||
`[omniroute-v2] snapshot write failed for ${providerId}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}, keeping the in-memory entry`
|
||||
);
|
||||
try {
|
||||
await unlink(tmp);
|
||||
} catch {
|
||||
// Ignore: the temp file may not exist (mkdir failed first).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,14 @@ import { assertContext } from "./compat.js";
|
||||
import { type ApiKeyOrigin, resolveApiKey, warnIfMissing } from "./credentials.js";
|
||||
import { createSourceErrorReporter } from "./enrichment-report.js";
|
||||
import { sanitizeToolSchemasFor } from "./gemini-language.js";
|
||||
import { PLUGIN_ID, parsePluginOptions, resolveTimeouts, type PluginOptions } from "./options.js";
|
||||
import {
|
||||
MANAGEMENT_TOKEN_ENV_VAR,
|
||||
PLUGIN_ID,
|
||||
parsePluginOptions,
|
||||
resolveManagementReadToken,
|
||||
resolveTimeouts,
|
||||
type PluginOptions,
|
||||
} from "./options.js";
|
||||
|
||||
/**
|
||||
* A fetch result that says whether it succeeded. Returning a bare `[]` on
|
||||
@@ -61,7 +68,7 @@ function toResolvedOptions(parsed: PluginOptions): ResolvedOptions {
|
||||
providerId: parsed.providerId,
|
||||
baseURL: parsed.baseURL,
|
||||
apiKey: parsed.apiKey ?? process.env.OMNIROUTE_API_KEY ?? "",
|
||||
managementReadToken: parsed.managementReadToken,
|
||||
managementReadToken: resolveManagementReadToken(parsed.managementReadToken),
|
||||
timeoutMs: parsed.timeoutMs,
|
||||
timeouts: parsed.timeouts,
|
||||
logLevel: parsed.logLevel,
|
||||
@@ -93,6 +100,16 @@ export default define({
|
||||
resolved.logLevel = parsed.logLevel;
|
||||
resolved.startupDebug = parsed.startupDebug;
|
||||
log.info(`[omniroute-v2] init providerId=${X}`);
|
||||
// The inference key stands in below when no management token is set, and
|
||||
// gateways usually reject that stand-in with 401/403. Say so once here,
|
||||
// before any fetch, instead of letting the refusal surface per endpoint.
|
||||
if (resolved.managementReadToken === undefined) {
|
||||
log.warn(
|
||||
`[omniroute-v2] no management token configured: management endpoints (/api/*) will reuse the inference key, ` +
|
||||
`which gateways usually reject with 401/403. Set "managementReadToken" in the plugin options ` +
|
||||
`or export ${MANAGEMENT_TOKEN_ENV_VAR}.`
|
||||
);
|
||||
}
|
||||
|
||||
// v1 parity port: in-memory TTL + disk snapshot. The memory key
|
||||
// `baseURL::sha256(creds)` isolates credential tuples (prod vs
|
||||
@@ -297,7 +314,7 @@ export default define({
|
||||
};
|
||||
if (models.length > 0) {
|
||||
state.entries.set(cacheKey, snapshot);
|
||||
await writeDiskSnapshot(X, snapshot, identityFingerprint);
|
||||
await writeDiskSnapshot(X, snapshot, identityFingerprint, log);
|
||||
}
|
||||
void optional.then(
|
||||
(parts) => upgradeWithOptional(snapshot, parts),
|
||||
@@ -344,7 +361,7 @@ export default define({
|
||||
if (unchanged) return;
|
||||
state.entries.set(cacheKey, upgraded);
|
||||
if (upgraded.models.length > 0) {
|
||||
await writeDiskSnapshot(X, upgraded, identityFingerprint);
|
||||
await writeDiskSnapshot(X, upgraded, identityFingerprint, log);
|
||||
}
|
||||
// Reload only when the optional tier actually moved: the catalog
|
||||
// fingerprint covers ids alone, so without this the host would rebuild
|
||||
|
||||
@@ -61,6 +61,21 @@ const pluginOptionsSchema = z
|
||||
|
||||
export type PluginOptions = z.infer<typeof pluginOptionsSchema>;
|
||||
|
||||
/** Environment source for the management token (option wins over this). */
|
||||
export const MANAGEMENT_TOKEN_ENV_VAR = "OMNIROUTE_MANAGEMENT_API_KEY";
|
||||
|
||||
/**
|
||||
* Resolve the management token: a non-empty option wins, then a non-empty
|
||||
* environment value, else absent. Empty counts as absent on both inputs, the
|
||||
* same rule the inference key follows; no trimming, the token is opaque.
|
||||
*/
|
||||
export function resolveManagementReadToken(optionValue: string | undefined): string | undefined {
|
||||
if (optionValue !== undefined && optionValue.length > 0) return optionValue;
|
||||
const fromEnv = process.env[MANAGEMENT_TOKEN_ENV_VAR];
|
||||
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Per-endpoint timeout defaults (v1 parity). `timeoutMs` is the global fallback. */
|
||||
export const DEFAULT_TIMEOUT_MS = 10_000 as const;
|
||||
/** Auto-combos keep the v1 5s budget; the field is resolved now for the P3 port. */
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
diskSnapshotPath,
|
||||
readDiskSnapshot,
|
||||
writeDiskSnapshot,
|
||||
type CatalogSnapshot,
|
||||
} from "../src/cache.js";
|
||||
|
||||
function isolateDisk(): { dir: string; restore: () => void } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-disk-atomic-"));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
return {
|
||||
dir,
|
||||
restore: () => {
|
||||
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSnapshot(models: string[] = ["m-a"]): CatalogSnapshot {
|
||||
return {
|
||||
models: models.map((id) => ({ id })),
|
||||
combos: [],
|
||||
autoCombos: [],
|
||||
providers: [],
|
||||
fetchedAt: Date.now(),
|
||||
} as unknown as CatalogSnapshot;
|
||||
}
|
||||
|
||||
function makeLogger() {
|
||||
const messages: string[] = [];
|
||||
return {
|
||||
messages,
|
||||
logger: { warn: (message: string) => void messages.push(message) },
|
||||
};
|
||||
}
|
||||
|
||||
// Entries next to the destination other than the destination itself: any
|
||||
// leftover temp file after a successful write shows up here.
|
||||
function strayEntries(file: string): string[] {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dirname(file));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries.filter((entry) => entry !== file.split("/").pop());
|
||||
}
|
||||
|
||||
// The writer names its temp file `${file}.${pid}.${counter}` with a
|
||||
// module-monotone counter starting at 0, built after the empty-models and
|
||||
// size-cap guards (an over-cap call consumes no counter value). Tests in this
|
||||
// file run sequentially in one process, so the attempt table below predicts
|
||||
// every temp path exactly:
|
||||
// over-cap: no counter use | failed write A: 0, failed write B: 1 |
|
||||
// interrupted overwrite A: 2, interrupted overwrite B: 3 | mkdir failure: 4 |
|
||||
// truncated read: 5 | success: 6 | permissions: 7 | round-trip: 8, 9.
|
||||
function predictedTmp(file: string, counter: number): string {
|
||||
return `${file}.${process.pid}.${counter}`;
|
||||
}
|
||||
|
||||
describe("disk snapshot atomic write, strict version, traced give-ups", () => {
|
||||
it("ignores a newer snapshot version without throwing", async () => {
|
||||
const disk = isolateDisk();
|
||||
try {
|
||||
const file = diskSnapshotPath("t1-future");
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
// A writer from the future persists version 3; this reader must
|
||||
// treat it as "no snapshot" instead of trusting unknown data.
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
v: 3,
|
||||
identityFingerprint: "fp-1",
|
||||
models: [{ id: "m-future" }],
|
||||
combos: [],
|
||||
writtenAt: Date.now(),
|
||||
})
|
||||
);
|
||||
const back = await readDiskSnapshot("t1-future", "fp-1");
|
||||
assert.equal(back, undefined);
|
||||
} finally {
|
||||
disk.restore();
|
||||
rmSync(disk.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("traces an over-cap write and leaves no destination behind", async () => {
|
||||
const disk = isolateDisk();
|
||||
try {
|
||||
const { messages, logger } = makeLogger();
|
||||
const bigId = `huge-${"x".repeat(33 * 1024 * 1024)}`;
|
||||
await writeDiskSnapshot("t2-cap", makeSnapshot([bigId]), "fp-1", logger);
|
||||
const file = diskSnapshotPath("t2-cap");
|
||||
assert.equal(existsSync(file), false);
|
||||
assert.deepEqual(strayEntries(file), []);
|
||||
assert.match(messages.join("\n"), /exceeds|too large|size cap/i);
|
||||
} finally {
|
||||
disk.restore();
|
||||
rmSync(disk.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("a failed write leaves no destination behind and is traced", async () => {
|
||||
const disk = isolateDisk();
|
||||
const file = diskSnapshotPath("t3a-fail");
|
||||
const blocker = predictedTmp(file, 1);
|
||||
try {
|
||||
const { messages, logger } = makeLogger();
|
||||
await writeDiskSnapshot("t3a-fail", makeSnapshot(["m-before"]), "fp-1", logger);
|
||||
// Plant a directory at the next temp path: the write fails with
|
||||
// EISDIR before any rename, deterministically, on every platform.
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
mkdirSync(blocker, { recursive: true });
|
||||
await writeDiskSnapshot("t3a-fail", makeSnapshot(["m-after"]), "fp-1", logger);
|
||||
assert.equal(existsSync(file), true);
|
||||
const back = await readDiskSnapshot("t3a-fail", "fp-1");
|
||||
assert.deepEqual(
|
||||
(back?.models ?? []).map((entry) => entry.id),
|
||||
["m-before"]
|
||||
);
|
||||
assert.match(messages.join("\n"), /failed|EISDIR|error/i);
|
||||
} finally {
|
||||
rmSync(blocker, { recursive: true, force: true });
|
||||
disk.restore();
|
||||
rmSync(disk.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("an interrupted overwrite keeps the previous snapshot", async () => {
|
||||
const disk = isolateDisk();
|
||||
const file = diskSnapshotPath("t3b-keep");
|
||||
const blocker = predictedTmp(file, 3);
|
||||
try {
|
||||
const { messages, logger } = makeLogger();
|
||||
await writeDiskSnapshot("t3b-keep", makeSnapshot(["m-before"]), "fp-1", logger);
|
||||
const before = readFileSync(file, "utf8");
|
||||
mkdirSync(blocker, { recursive: true });
|
||||
await writeDiskSnapshot("t3b-keep", makeSnapshot(["m-after"]), "fp-1", logger);
|
||||
assert.equal(readFileSync(file, "utf8"), before);
|
||||
const back = await readDiskSnapshot("t3b-keep", "fp-1");
|
||||
assert.deepEqual(
|
||||
(back?.models ?? []).map((entry) => entry.id),
|
||||
["m-before"]
|
||||
);
|
||||
assert.match(messages.join("\n"), /failed|EISDIR|error/i);
|
||||
} finally {
|
||||
rmSync(blocker, { recursive: true, force: true });
|
||||
disk.restore();
|
||||
rmSync(disk.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("a mkdir failure is traced and writes nothing", async () => {
|
||||
const disk = isolateDisk();
|
||||
try {
|
||||
const { messages, logger } = makeLogger();
|
||||
// A file planted at the plugins path makes mkdir fail
|
||||
// deterministically (EEXIST on mkdir, ENOTDIR on direct writeFile).
|
||||
writeFileSync(join(disk.dir, "plugins"), "blocker");
|
||||
await writeDiskSnapshot("t3b-bis", makeSnapshot(["m-a"]), "fp-1", logger);
|
||||
assert.equal(existsSync(diskSnapshotPath("t3b-bis")), false);
|
||||
assert.match(messages.join("\n"), /failed|EEXIST|ENOTDIR|error/i);
|
||||
} finally {
|
||||
disk.restore();
|
||||
rmSync(disk.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("a truncated file reads as no snapshot without throwing", async () => {
|
||||
const disk = isolateDisk();
|
||||
try {
|
||||
const { logger } = makeLogger();
|
||||
await writeDiskSnapshot("t4-truncated", makeSnapshot(["m-a"]), "fp-1", logger);
|
||||
const file = diskSnapshotPath("t4-truncated");
|
||||
const full = readFileSync(file, "utf8");
|
||||
writeFileSync(file, full.slice(0, Math.floor(full.length / 2)));
|
||||
const back = await readDiskSnapshot("t4-truncated", "fp-1");
|
||||
assert.equal(back, undefined);
|
||||
} finally {
|
||||
disk.restore();
|
||||
rmSync(disk.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("a successful write leaves no entry but the destination", async () => {
|
||||
const disk = isolateDisk();
|
||||
try {
|
||||
await writeDiskSnapshot("t5-clean", makeSnapshot(["m-a"]), "fp-1");
|
||||
const file = diskSnapshotPath("t5-clean");
|
||||
assert.deepEqual(strayEntries(file), []);
|
||||
} finally {
|
||||
disk.restore();
|
||||
rmSync(disk.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("the replaced snapshot stays owner-only", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("file mode semantics are POSIX-only");
|
||||
return;
|
||||
}
|
||||
const disk = isolateDisk();
|
||||
try {
|
||||
await writeDiskSnapshot("t6-mode", makeSnapshot(["m-a"]), "fp-1");
|
||||
const file = diskSnapshotPath("t6-mode");
|
||||
assert.equal((statSync(file).mode & 0o077) === 0, true);
|
||||
} finally {
|
||||
disk.restore();
|
||||
rmSync(disk.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips a valid snapshot with and without a logger", async () => {
|
||||
const disk = isolateDisk();
|
||||
try {
|
||||
const { logger } = makeLogger();
|
||||
const snapshot = makeSnapshot(["m-a"]);
|
||||
await writeDiskSnapshot("t7-roundtrip", snapshot, "fp-1");
|
||||
const plain = await readDiskSnapshot("t7-roundtrip", "fp-1");
|
||||
assert.deepEqual(
|
||||
(plain?.models ?? []).map((entry) => entry.id),
|
||||
["m-a"]
|
||||
);
|
||||
await writeDiskSnapshot("t7-roundtrip", snapshot, "fp-1", logger);
|
||||
const logged = await readDiskSnapshot("t7-roundtrip", "fp-1", logger);
|
||||
assert.deepEqual(
|
||||
(logged?.models ?? []).map((entry) => entry.id),
|
||||
["m-a"]
|
||||
);
|
||||
} finally {
|
||||
disk.restore();
|
||||
rmSync(disk.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,33 @@ interface CapturedCall {
|
||||
kind: "catalog" | "integration";
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until `read()` stops changing, then return the settled value.
|
||||
*
|
||||
* The plugin's optional tier lands asynchronously after a publish. Waiting for
|
||||
* it with a fixed `sleep(5)` raced the work: under load the tier arrived after
|
||||
* the sleep, so the *next* assertion counted its reload and read 2 where it
|
||||
* expected 1. Polling until the value holds steady for a few consecutive turns
|
||||
* ties the wait to the work instead of to the clock.
|
||||
*/
|
||||
async function settle<T>(read: () => T, quietTurns = 3, timeoutMs = 5000): Promise<T> {
|
||||
const { setTimeout: sleep } = await import("node:timers/promises");
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last = read();
|
||||
let stable = 0;
|
||||
while (stable < quietTurns && Date.now() < deadline) {
|
||||
await sleep(5);
|
||||
const current = read();
|
||||
if (current === last) {
|
||||
stable += 1;
|
||||
} else {
|
||||
last = current;
|
||||
stable = 0;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
interface FakeCtx {
|
||||
options: Record<string, unknown>;
|
||||
catalog: {
|
||||
@@ -163,7 +190,6 @@ describe("plugin-v2 entrypoint", () => {
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { setTimeout: sleep } = await import("node:timers/promises");
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-lazy-"));
|
||||
const prevDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = dir;
|
||||
@@ -222,16 +248,15 @@ describe("plugin-v2 entrypoint", () => {
|
||||
await cb(draft);
|
||||
assert.equal(reloads, 0, "the first publish sets the baseline, it does not reload");
|
||||
assert.equal(modelsCall, 1);
|
||||
await sleep(5);
|
||||
// The optional tier lands after that first publish and brings combos and
|
||||
// the overlay with it — one reload, so the picker shows them without
|
||||
// waiting for the next refresh.
|
||||
const afterFirstUpgrade = reloads;
|
||||
const afterFirstUpgrade = await settle(() => reloads);
|
||||
assert.ok(afterFirstUpgrade <= 1, `at most one reload for the first upgrade, got ${reloads}`);
|
||||
await cb(draft);
|
||||
assert.equal(reloads, afterFirstUpgrade + 1, "a new model id reloads once");
|
||||
assert.equal(modelsCall, 2);
|
||||
await sleep(5);
|
||||
await settle(() => reloads);
|
||||
await cb(draft);
|
||||
assert.equal(reloads, afterFirstUpgrade + 1, "an identical run never reloads");
|
||||
assert.equal(modelsCall, 3);
|
||||
|
||||
371
@omniroute/opencode-plugin-v2/tests/management-token-env.test.ts
Normal file
371
@omniroute/opencode-plugin-v2/tests/management-token-env.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import plugin from "../src/index.js";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
|
||||
const MODELS_URL = "https://gw.example.com/v1/models";
|
||||
const COMBOS_URL = "https://gw.example.com/api/combos";
|
||||
const PRICING_MODELS_URL = "https://gw.example.com/api/pricing/models";
|
||||
|
||||
const MGMT_ENV_VAR = "OMNIROUTE_MANAGEMENT_API_KEY";
|
||||
const INFERENCE_ENV_VAR = "OMNIROUTE_API_KEY";
|
||||
|
||||
function okJson(body: unknown) {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => body };
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
seen: Map<string, string>;
|
||||
warns: string[];
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
function installHarness(combos: unknown[]): Harness {
|
||||
const seen = new Map<string, string>();
|
||||
const warns: string[] = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
const origWarn = console.warn;
|
||||
const origLog = console.log;
|
||||
const origError = console.error;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
console.log = () => {};
|
||||
console.error = (...args: unknown[]) => {
|
||||
warns.push(String(args[0]));
|
||||
};
|
||||
globalThis.fetch = (async (url: unknown, init?: { headers?: Record<string, string> }) => {
|
||||
const href = String(url);
|
||||
seen.set(href, String(init?.headers?.Authorization ?? ""));
|
||||
if (href.includes("/api/combos/auto")) return okJson({ combos: [] });
|
||||
if (href.includes("/api/pricing/models")) {
|
||||
return okJson({
|
||||
providers: {
|
||||
demo: {
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
models: [{ id: "team-combo", name: "Team Combo" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (href.includes("/api/pricing")) return okJson({});
|
||||
if (href.includes("/api/free-tier/summary")) return okJson({ perModel: [] });
|
||||
if (href.includes("/api/combos")) return okJson({ combos });
|
||||
return okJson({ data: [{ id: "m1" }] });
|
||||
}) as typeof fetch;
|
||||
return {
|
||||
seen,
|
||||
warns,
|
||||
restore() {
|
||||
globalThis.fetch = origFetch;
|
||||
console.warn = origWarn;
|
||||
console.log = origLog;
|
||||
console.error = origError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withIsolatedEnv<T>(
|
||||
mgmt: string | undefined,
|
||||
inference: string | undefined,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const prevMgmt = process.env[MGMT_ENV_VAR];
|
||||
const prevInference = process.env[INFERENCE_ENV_VAR];
|
||||
// Like tests/management-token.test.ts:176-180: a fresh OPENCODE_DATA_DIR
|
||||
// per case keeps the real disk snapshot out of the run, so a filtered 'it'
|
||||
// never gets a warm snapshot served without fetch.
|
||||
const prevDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-mgmt-env-"));
|
||||
if (mgmt === undefined) delete process.env[MGMT_ENV_VAR];
|
||||
else process.env[MGMT_ENV_VAR] = mgmt;
|
||||
if (inference === undefined) delete process.env[INFERENCE_ENV_VAR];
|
||||
else process.env[INFERENCE_ENV_VAR] = inference;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
if (prevMgmt === undefined) delete process.env[MGMT_ENV_VAR];
|
||||
else process.env[MGMT_ENV_VAR] = prevMgmt;
|
||||
if (prevInference === undefined) delete process.env[INFERENCE_ENV_VAR];
|
||||
else process.env[INFERENCE_ENV_VAR] = prevInference;
|
||||
}
|
||||
}
|
||||
|
||||
function setupHarness(options: Record<string, unknown>) {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options,
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
}
|
||||
|
||||
function stubDraft() {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const key = pid + "/" + mid;
|
||||
let entry = published.get(key);
|
||||
if (entry === undefined) {
|
||||
entry = { id: mid, providerID: pid };
|
||||
published.set(key, entry);
|
||||
}
|
||||
fn(entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { draft, published };
|
||||
}
|
||||
|
||||
function fallbackWarns(warns: string[]): string[] {
|
||||
return warns.filter((w) => w.includes("managementReadToken"));
|
||||
}
|
||||
|
||||
async function runSetup(ctx: unknown): Promise<void> {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
}
|
||||
|
||||
describe("plugin-v2 management token environment source", () => {
|
||||
it("uses the managementReadToken option for /api/* while models keep apiKey", async () => {
|
||||
await withIsolatedEnv(undefined, undefined, async () => {
|
||||
const h = installHarness([]);
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setupHarness({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: "mgmt-option-token",
|
||||
});
|
||||
await runSetup(ctx);
|
||||
assert.deepEqual(fallbackWarns(h.warns), []);
|
||||
const { draft } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token");
|
||||
assert.equal(h.seen.get(MODELS_URL), "Bearer chat-key");
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the management token from the environment when the option is absent", async () => {
|
||||
await withIsolatedEnv("mgmt-env-token", undefined, async () => {
|
||||
const h = installHarness([]);
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setupHarness({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
});
|
||||
await runSetup(ctx);
|
||||
assert.deepEqual(fallbackWarns(h.warns), []);
|
||||
const { draft } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-env-token");
|
||||
assert.equal(h.seen.get(MODELS_URL), "Bearer chat-key");
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers the option over the environment", async () => {
|
||||
await withIsolatedEnv("mgmt-env-token", undefined, async () => {
|
||||
const h = installHarness([]);
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setupHarness({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: "mgmt-option-token",
|
||||
});
|
||||
await runSetup(ctx);
|
||||
assert.deepEqual(fallbackWarns(h.warns), []);
|
||||
const { draft } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token");
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the inference key with a single early warning when neither is set", async () => {
|
||||
await withIsolatedEnv(undefined, undefined, async () => {
|
||||
const h = installHarness([]);
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setupHarness({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
});
|
||||
await runSetup(ctx);
|
||||
const atSetup = fallbackWarns(h.warns);
|
||||
assert.equal(
|
||||
atSetup.length,
|
||||
1,
|
||||
`expected exactly one early fallback warning, got: ${JSON.stringify(h.warns)}`
|
||||
);
|
||||
assert.match(atSetup[0] ?? "", /managementReadToken/);
|
||||
assert.match(atSetup[0] ?? "", new RegExp(MGMT_ENV_VAR));
|
||||
assert.ok(!(atSetup[0] ?? "").includes("chat-key"), "warning must not leak the key");
|
||||
const { draft } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.equal(h.seen.get(COMBOS_URL), "Bearer chat-key");
|
||||
assert.equal(
|
||||
fallbackWarns(h.warns).length,
|
||||
1,
|
||||
"the fallback warning stays a single setup-time notice"
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an empty option as absent so the environment wins", async () => {
|
||||
await withIsolatedEnv("mgmt-env-token", undefined, async () => {
|
||||
const h = installHarness([]);
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setupHarness({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: "",
|
||||
});
|
||||
await runSetup(ctx);
|
||||
assert.deepEqual(fallbackWarns(h.warns), []);
|
||||
const { draft } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-env-token");
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an empty environment value as absent so the option wins", async () => {
|
||||
await withIsolatedEnv("", undefined, async () => {
|
||||
const h = installHarness([]);
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setupHarness({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: "mgmt-option-token",
|
||||
});
|
||||
await runSetup(ctx);
|
||||
assert.deepEqual(fallbackWarns(h.warns), []);
|
||||
const { draft } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token");
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back with a warning when both the option and the environment are empty", async () => {
|
||||
await withIsolatedEnv("", undefined, async () => {
|
||||
const h = installHarness([]);
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setupHarness({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: "",
|
||||
});
|
||||
await runSetup(ctx);
|
||||
assert.equal(fallbackWarns(h.warns).length, 1);
|
||||
const { draft } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.equal(h.seen.get(COMBOS_URL), "Bearer chat-key");
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("enriches the catalog from the environment token alone", async () => {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const draft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as unknown as CatalogDraft;
|
||||
let seenCombos = "";
|
||||
let seenPricing = "";
|
||||
const res = await withIsolatedEnv("mgmt-env-token", undefined, async () =>
|
||||
publishCatalog(
|
||||
draft,
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: process.env[MGMT_ENV_VAR],
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
},
|
||||
{
|
||||
fetcher: async () => [{ id: "m1" }],
|
||||
combosFetcher: async (_base, token) => {
|
||||
seenCombos = token;
|
||||
return [{ id: "team-combo", models: [{ kind: "model", model: "m1" }] }];
|
||||
},
|
||||
enrichmentFetcher: async (_base, token) => {
|
||||
seenPricing = token;
|
||||
// The process env is the source under test: the resolver output
|
||||
// flows in through the option above, so report success only when
|
||||
// the flow under test actually carried it.
|
||||
if (token !== "mgmt-env-token") return new Map();
|
||||
return new Map([["team-combo", { name: "Team Combo" }]]);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 0 });
|
||||
assert.equal(seenCombos, "mgmt-env-token");
|
||||
assert.equal(seenPricing, "mgmt-env-token");
|
||||
const entry = models.get("omniroute/team-combo");
|
||||
assert.ok(entry, "expected the combo entry in the published catalog");
|
||||
assert.equal(entry?.name, "Team Combo");
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.28.1"
|
||||
"esbuild": "^0.28.1",
|
||||
"toml": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
21
AGENTS.md
21
AGENTS.md
@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
|
||||
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 356 LLM providers, auto-fallback.
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 358 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 (173 migrations) |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (176 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
@@ -578,14 +578,31 @@ own dedicated branch, and you MUST confirm the base branch with the operator bef
|
||||
# HARD LINKS (`cp -al`), never a symlink: ~5s for the whole tree and near-zero extra
|
||||
# disk (the inodes are shared), and unlike a symlink it does not break the dev server.
|
||||
cp -al "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
|
||||
# `.husky/_` is gitignored, so a fresh worktree does NOT have it and
|
||||
# `core.hooksPath=.husky/_` then points at a directory that does not exist —
|
||||
# every pre-commit gate goes silently mute. Copy it too.
|
||||
cp -a "$(git -C <main_checkout> rev-parse --show-toplevel)/.husky/_" .husky/_
|
||||
```
|
||||
|
||||
`scripts/dev/new-worktree.sh <branch> [base]` does all of the above (canonical path,
|
||||
hard-linked `node_modules`, `.husky/_`) and then **verifies** the hook is actually
|
||||
executable, so prefer it over running the steps by hand.
|
||||
|
||||
**Never `ln -s` node_modules.** Turbopack rejects a symlink that resolves outside the
|
||||
project root, so `npm run dev` dies with a FATAL panic (`Symlink [project]/node_modules
|
||||
is invalid, it points out of the filesystem root`) while typecheck, lint and the test
|
||||
runners all keep passing — the error names "filesystem root", not the worktree, so it
|
||||
reads like a Next/build bug and costs real time to trace (incident 2026-07-31, #9043).
|
||||
|
||||
**A worktree without `.husky/_` runs NO pre-commit gate — and says nothing.** `git`
|
||||
resolves `core.hooksPath` relative to the worktree top; when the directory is missing it
|
||||
simply finds no hook and commits. Nothing is printed, the commit succeeds, and the
|
||||
identity/lint/docs gates never ran. This is how 59 commits carrying a stale identity
|
||||
override (name of a contributor + the maintainer's e-mail) got past
|
||||
`scripts/check/check-git-identity.sh` between 2026-08-29 and 09-02 — they were all made in
|
||||
`cp -al` worktrees. Verify with `ls .husky/_/pre-commit` inside a new worktree, or just use
|
||||
`scripts/dev/new-worktree.sh`, which fails loudly when the hook is not executable.
|
||||
|
||||
3. **Work, commit, push, open the PR — all from inside the worktree.** Never `git checkout` a
|
||||
different branch inside a worktree another session might share.
|
||||
4. **Tear down only your own** worktree + branch when done, from the main checkout:
|
||||
|
||||
35
README.md
35
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 → 356 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 356 AI providers · 150+ free tiers · ~1.47B 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 → 358 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 358 AI providers · 150+ free tiers · ~1.47B free tokens/mo · 19 routing strategies · $0 to start."/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **444 free-tier entries across 34 recurring pool keys** and computes the token headline from the **16 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
|
||||
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **443 free-tier entries across 34 recurring pool keys** and computes the token headline from the **16 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
|
||||
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.47B free tokens per month steady, up to ~2.10B in the first month with signup credits, from 34 documented recurring pool keys covering 444 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 16 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.47B free tokens per month steady, up to ~2.07B in the first month with signup credits, from 34 documented recurring pool keys covering 443 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 16 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
|
||||
|
||||
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
|
||||
>
|
||||
@@ -133,7 +133,7 @@
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<b>🌐 In 51 languages</b>
|
||||
<b>🌐 In 66 languages</b>
|
||||
<br/><br/>
|
||||
<a href="README.md"><img src="docs/assets/flags/us.svg" width="30" alt="English (en)" title="English (en)"></a>
|
||||
<a href="docs/i18n/pt-BR/README.md"><img src="docs/assets/flags/br.svg" width="30" alt="Português — Brasil (pt-BR)" title="Português — Brasil (pt-BR)"></a>
|
||||
@@ -186,6 +186,21 @@
|
||||
<a href="docs/i18n/sl/README.md"><img src="docs/assets/flags/si.svg" width="30" alt="Slovenščina (sl)" title="Slovenščina (sl)"></a>
|
||||
<a href="docs/i18n/mt/README.md"><img src="docs/assets/flags/mt.svg" width="30" alt="Malti (mt)" title="Malti (mt)"></a>
|
||||
<a href="docs/i18n/ga/README.md"><img src="docs/assets/flags/ie.svg" width="30" alt="Gaeilge (ga)" title="Gaeilge (ga)"></a>
|
||||
<a href="docs/i18n/kn/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="ಕನ್ನಡ (kn)" title="ಕನ್ನಡ (kn)"></a>
|
||||
<a href="docs/i18n/ml/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="മലയാളം (ml)" title="മലയാളം (ml)"></a>
|
||||
<a href="docs/i18n/or/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="ଓଡ଼ିଆ (or)" title="ଓଡ଼ିଆ (or)"></a>
|
||||
<a href="docs/i18n/pa/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="ਪੰਜਾਬੀ (pa)" title="ਪੰਜਾਬੀ (pa)"></a>
|
||||
<a href="docs/i18n/ne/README.md"><img src="docs/assets/flags/np.svg" width="30" alt="नेपाली (ne)" title="नेपाली (ne)"></a>
|
||||
<a href="docs/i18n/si/README.md"><img src="docs/assets/flags/lk.svg" width="30" alt="සිංහල (si)" title="සිංහල (si)"></a>
|
||||
<a href="docs/i18n/my/README.md"><img src="docs/assets/flags/mm.svg" width="30" alt="မြန်မာ (my)" title="မြန်မာ (my)"></a>
|
||||
<a href="docs/i18n/km/README.md"><img src="docs/assets/flags/kh.svg" width="30" alt="ខ្មែរ (km)" title="ខ្មែរ (km)"></a>
|
||||
<a href="docs/i18n/ha/README.md"><img src="docs/assets/flags/ng.svg" width="30" alt="Hausa (ha)" title="Hausa (ha)"></a>
|
||||
<a href="docs/i18n/yo/README.md"><img src="docs/assets/flags/ng.svg" width="30" alt="Yorùbá (yo)" title="Yorùbá (yo)"></a>
|
||||
<a href="docs/i18n/ig/README.md"><img src="docs/assets/flags/ng.svg" width="30" alt="Igbo (ig)" title="Igbo (ig)"></a>
|
||||
<a href="docs/i18n/am/README.md"><img src="docs/assets/flags/et.svg" width="30" alt="አማርኛ (am)" title="አማርኛ (am)"></a>
|
||||
<a href="docs/i18n/uz/README.md"><img src="docs/assets/flags/uz.svg" width="30" alt="Oʻzbekcha (uz)" title="Oʻzbekcha (uz)"></a>
|
||||
<a href="docs/i18n/ka/README.md"><img src="docs/assets/flags/ge.svg" width="30" alt="ქართული (ka)" title="ქართული (ka)"></a>
|
||||
<a href="docs/i18n/hy/README.md"><img src="docs/assets/flags/am.svg" width="30" alt="Հայերեն (hy)" title="Հայերեն (hy)"></a>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
@@ -218,7 +233,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 52 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
|
||||
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 358 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 358 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 52 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
@@ -471,7 +486,7 @@ All **19** strategies — mix & match per combo step:
|
||||
|
||||
</div>
|
||||
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 356 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
|
||||
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 358 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
|
||||
|
||||
<sub>📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
|
||||
|
||||
@@ -529,7 +544,7 @@ Pix copia-e-cola:
|
||||
|
||||
The main free-tier headline remains **~1.47B tokens/month** from the documented,
|
||||
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
|
||||
month to **~2.10B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
month to **~2.07B**. Radar is an optional, signed catalog overlay for people who want fresher
|
||||
free-model availability between OmniRoute releases; the community catalog and every existing free
|
||||
feature remain free.
|
||||
|
||||
@@ -657,7 +672,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||
|
||||
</div>
|
||||
|
||||
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **444 per-model rows**, **34 recurring pools** and **52 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
|
||||
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **443 per-model rows**, **34 recurring pools** and **52 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -1253,7 +1268,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>>=22.22.2 <23 || >=24.0.0 <27</code></td></tr>
|
||||
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
|
||||
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 173 migrations</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 176 migrations</td></tr>
|
||||
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
|
||||
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
|
||||
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
|
||||
@@ -1316,7 +1331,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>16-factor scoring, mode packs, self-healing</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 34 documented recurring pools / 444 cataloged free-tier entries</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 34 documented recurring pools / 443 cataloged free-tier entries</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
|
||||
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -13,6 +13,7 @@ const OMNIROUTE_ENV_VARS = [
|
||||
"OMNIROUTE_API_KEY",
|
||||
"OMNIROUTE_BASE_URL",
|
||||
"OMNIROUTE_HTTP_TIMEOUT_MS",
|
||||
"OMNIROUTE_READY_TIMEOUT_MS",
|
||||
];
|
||||
|
||||
const ENV_DEFAULTS = {
|
||||
|
||||
@@ -54,6 +54,34 @@ async function openBrowser(url) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors src/lib/oauth/providers.ts::isLoopbackHostname — used here to detect
|
||||
// when the redirect_uri the server resolved (and the authorize URL now
|
||||
// advertises) points at a loopback address the CLI never binds a listener on
|
||||
// (issue #12413). Returns false on an unparseable URI rather than throwing.
|
||||
function isLoopbackHost(uri) {
|
||||
try {
|
||||
return /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(new URL(uri).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function printLoopbackRedirectWarning(providerId, redirectUri) {
|
||||
process.stdout.write(
|
||||
`Note: the authorize URL below advertises ${redirectUri}, but this CLI does not\n` +
|
||||
"listen on that port. Right after you approve, the browser is expected to\n" +
|
||||
"show a connection error (e.g. \"This site can't be reached\" / \n" +
|
||||
"ERR_CONNECTION_REFUSED) — that is normal, not a failure. Copy the full URL\n" +
|
||||
"from the address bar anyway and paste it below.\n"
|
||||
);
|
||||
if (providerId === "antigravity") {
|
||||
process.stdout.write(
|
||||
"Tip: `omniroute login antigravity` captures the code automatically and\n" +
|
||||
"avoids that error page entirely.\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function targetApiOptions(opts = {}) {
|
||||
return {
|
||||
baseUrl: opts.baseUrl,
|
||||
@@ -110,6 +138,10 @@ async function runBrowserFlow(def, opts) {
|
||||
const { codeVerifier, state, redirectUri: returnedRedirectUri } = start;
|
||||
const finalRedirectUri = returnedRedirectUri || redirectUri;
|
||||
|
||||
if (finalRedirectUri && isLoopbackHost(finalRedirectUri)) {
|
||||
printLoopbackRedirectWarning(def.id, finalRedirectUri);
|
||||
}
|
||||
|
||||
process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`);
|
||||
if (opts.browser !== false) await openBrowser(url);
|
||||
process.stdout.write(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { platform, totalmem } from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
|
||||
import { writePidFile, cleanupPidFile, waitForServer, resolveReadyTimeoutMs } from "../utils/pid.mjs";
|
||||
import {
|
||||
ServerSupervisor,
|
||||
detectMitmCrash,
|
||||
@@ -58,6 +58,11 @@ export function registerServe(program) {
|
||||
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
|
||||
.option("--tray", t("serve.tray") || "Start in the system tray (desktop only)")
|
||||
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon")
|
||||
.option(
|
||||
"--ready-timeout <ms>",
|
||||
t("serve.ready_timeout") ||
|
||||
"Readiness probe timeout in ms (also OMNIROUTE_READY_TIMEOUT_MS, default 60000)"
|
||||
)
|
||||
.option(
|
||||
"--tls-cert <path>",
|
||||
t("serve.tls_cert") ||
|
||||
@@ -452,7 +457,8 @@ async function runWithSupervisor(
|
||||
});
|
||||
|
||||
if (!showLog) {
|
||||
waitForServer(dashboardPort, 60000).then(async (up) => {
|
||||
const readyTimeoutMs = resolveReadyTimeoutMs({ timeoutMs: opts.readyTimeout });
|
||||
waitForServer(dashboardPort, readyTimeoutMs).then(async (up) => {
|
||||
if (up) {
|
||||
if (useTray) {
|
||||
const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
@@ -489,10 +495,15 @@ async function runWithSupervisor(
|
||||
// reachable directly while the CLI still looks hung). Surface a clear diagnostic
|
||||
// plus whatever stdout/stderr the child buffered instead of going silent.
|
||||
export function reportReadinessTimeout(dashboardPort, supervisor) {
|
||||
const readyTimeoutMs = resolveReadyTimeoutMs();
|
||||
const seconds = Math.round(readyTimeoutMs / 1000);
|
||||
console.error(
|
||||
`\n\x1b[33m⚠ Server did not respond within 60s.\x1b[0m It may still be starting, or may` +
|
||||
`\n\x1b[33m⚠ Server did not respond within ${seconds}s.\x1b[0m It may still be starting, or may` +
|
||||
` have failed silently.`
|
||||
);
|
||||
console.error(
|
||||
` Tip: set OMNIROUTE_READY_TIMEOUT_MS=${readyTimeoutMs * 2} or --ready-timeout ${readyTimeoutMs * 2} for slower cold starts.`
|
||||
);
|
||||
console.error(` Try: curl -I http://localhost:${dashboardPort}/api/monitoring/health`);
|
||||
console.error(` Or: rerun with \x1b[36m--log\x1b[0m to see live server output.\n`);
|
||||
|
||||
|
||||
@@ -35,16 +35,24 @@ export function resolveOpencodeTarget(opts = {}) {
|
||||
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
|
||||
// Precedence: explicit --api-key flag > OMNIROUTE_API_KEY env var > active
|
||||
// context's management token. A context's accessToken/apiKey is a CLI
|
||||
// management credential (oma_live_...) with no /v1/* inference scope — it
|
||||
// must never silently outrank a real inference key the caller supplied
|
||||
// either as a flag or via the ambient env var (mirrors the explicit >
|
||||
// ambient-env > context precedence documented in bin/cli/api.mjs's
|
||||
// buildHeaders()). Only fall back to the context token when neither an
|
||||
// explicit flag nor the env var is set.
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
apiKey = c?.accessToken || c?.apiKey || "";
|
||||
} catch {
|
||||
/* no context auth */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey };
|
||||
}
|
||||
|
||||
@@ -177,8 +185,17 @@ export function registerSetupOpencode(program) {
|
||||
"--allow-container-write",
|
||||
"Write even when the target is inside a container and not mounted from the host"
|
||||
)
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupOpencodeCommand(opts);
|
||||
.action(async (opts, cmd) => {
|
||||
// Commander parses the ancestor program's own global --api-key option
|
||||
// (bin/cli/program.mjs, bound to .env("OMNIROUTE_API_KEY")) against any
|
||||
// occurrence of the flag in argv, so it wins the value even when the
|
||||
// user typed --api-key AFTER `setup-opencode` — this local option's own
|
||||
// `opts.apiKey` never sees it. cmd.optsWithGlobals() resolves to the
|
||||
// correct value either way ("globals overwrite locals" is exactly the
|
||||
// outcome we want here, since the global option is where the value
|
||||
// always actually lands).
|
||||
const resolvedOpts = { ...opts, apiKey: cmd.optsWithGlobals().apiKey ?? opts.apiKey };
|
||||
const code = await runSetupOpencodeCommand(resolvedOpts);
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
|
||||
1329
bin/cli/locales/am.json
Normal file
1329
bin/cli/locales/am.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -256,6 +256,7 @@
|
||||
"max_restarts": "Max crash restarts within 30s before giving up (default: 2)",
|
||||
"tray": "Start in the system tray (desktop only, opt-in)",
|
||||
"no_tray": "Disable system tray icon",
|
||||
"ready_timeout": "Readiness probe timeout in ms (also OMNIROUTE_READY_TIMEOUT_MS, default 60000)",
|
||||
"tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
|
||||
},
|
||||
|
||||
1329
bin/cli/locales/ha.json
Normal file
1329
bin/cli/locales/ha.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/hy.json
Normal file
1329
bin/cli/locales/hy.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/ig.json
Normal file
1329
bin/cli/locales/ig.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/ka.json
Normal file
1329
bin/cli/locales/ka.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/km.json
Normal file
1329
bin/cli/locales/km.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/kn.json
Normal file
1329
bin/cli/locales/kn.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/ml.json
Normal file
1329
bin/cli/locales/ml.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/my.json
Normal file
1329
bin/cli/locales/my.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/ne.json
Normal file
1329
bin/cli/locales/ne.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/or.json
Normal file
1329
bin/cli/locales/or.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/pa.json
Normal file
1329
bin/cli/locales/pa.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/si.json
Normal file
1329
bin/cli/locales/si.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/uz.json
Normal file
1329
bin/cli/locales/uz.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
bin/cli/locales/yo.json
Normal file
1329
bin/cli/locales/yo.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -254,6 +254,7 @@
|
||||
"max_restarts": "30 秒内的最大崩溃重启次数(默认:2)",
|
||||
"tray": "显示系统托盘图标(仅桌面,选择加入)",
|
||||
"no_tray": "禁用系统托盘图标",
|
||||
"ready_timeout": "就绪探测超时(毫秒)(也可用 OMNIROUTE_READY_TIMEOUT_MS,默认 60000)",
|
||||
"tls_cert": "用于提供 HTTPS 服务的 TLS 证书(PEM)路径(也可用 OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "用于提供 HTTPS 服务的 TLS 私钥(PEM)路径(也可用 OMNIROUTE_TLS_KEY)"
|
||||
},
|
||||
|
||||
@@ -254,6 +254,7 @@
|
||||
"max_restarts": "30 秒內的最大崩潰重啟次數(預設:2)",
|
||||
"tray": "顯示系統托盤圖示(僅桌面,選擇加入)",
|
||||
"no_tray": "停用系統托盤圖示",
|
||||
"ready_timeout": "就緒探測逾時(毫秒)(也可用 OMNIROUTE_READY_TIMEOUT_MS,預設 60000)",
|
||||
"tls_cert": "用於提供 HTTPS 服務的 TLS 憑證(PEM)路徑(也可用 OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "用於提供 HTTPS 服務的 TLS 私鑰(PEM)路徑(也可用 OMNIROUTE_TLS_KEY)"
|
||||
},
|
||||
|
||||
@@ -66,13 +66,30 @@ export function sleep(ms) {
|
||||
// #2460: Default raised from 15s to 60s so Windows users (slower Next.js
|
||||
// cold start due to filesystem watchers, antivirus, etc.) get a working
|
||||
// "server ready" signal instead of a phantom timeout while the server is
|
||||
// still booting. TCP fallback marks the server as ready when the port
|
||||
// still booting. #13369: Made configurable via OMNIROUTE_READY_TIMEOUT_MS
|
||||
// so operators on slow cold starts (e.g. 6+ min Windows boots) can raise
|
||||
// the budget instead of hitting the warning on every start.
|
||||
//
|
||||
// TCP fallback marks the server as ready when the port
|
||||
// has been listening for >= 3s consecutively AND the health route is
|
||||
// actively rejecting/resetting connections fast (route not mounted yet,
|
||||
// but the HTTP server is clearly alive and responsive) — never for a
|
||||
// socket that merely accepts TCP and then hangs without ever completing
|
||||
// a single request (#6800: that's a still-booting/CPU-bound process, not
|
||||
// a "route not mounted" gap, and must NOT be reported as ready).
|
||||
const DEFAULT_READY_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function resolveReadyTimeoutMs(overrides = {}) {
|
||||
if (typeof overrides.timeoutMs === "number" && overrides.timeoutMs > 0) {
|
||||
return overrides.timeoutMs;
|
||||
}
|
||||
const envValue = Number.parseInt(
|
||||
process.env.OMNIROUTE_READY_TIMEOUT_MS || "",
|
||||
10
|
||||
);
|
||||
return Number.isFinite(envValue) && envValue > 0 ? envValue : DEFAULT_READY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
export async function waitForServer(port, timeout = 60000) {
|
||||
const start = Date.now();
|
||||
let tcpListeningSince = null;
|
||||
|
||||
@@ -50,6 +50,34 @@ if (isVersionFastPath(process.argv)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Detect an unsupported Node.js runtime BEFORE the heavy `tsx/esm` import and
|
||||
// Commander's ~70-command registration chain run. That chain pulls in `ora` ->
|
||||
// the hoisted `string-width` package, whose module contains top-level ES2024
|
||||
// Unicode-set (`v` flag) regex literals. On a Node/V8 build that predates
|
||||
// `v`-flag support, those literals fail to even *parse*, throwing a bare
|
||||
// `SyntaxError: Invalid regular expression flags` deep inside a transitive
|
||||
// dependency instead of an actionable message (#12296). Skip this for the
|
||||
// same read-only invocations `shouldProvisionStorageKey` already exempts
|
||||
// (`--help`/`-h`, `help`/`completion`) — those still need the full command
|
||||
// registry to render their output, so an incompatible runtime crashing there
|
||||
// is a separate, pre-existing limitation this fix does not attempt to solve.
|
||||
if (shouldProvisionStorageKey(process.argv)) {
|
||||
const nodeSupport = getNodeRuntimeSupport();
|
||||
if (!nodeSupport.nodeCompatible) {
|
||||
const runtimeWarning = getNodeRuntimeWarning() || "Unsupported Node.js runtime detected.";
|
||||
console.error(
|
||||
`\x1b[31m✖ Node.js ${nodeSupport.nodeVersion} is not supported.\x1b[0m\n` +
|
||||
` ${runtimeWarning}\n` +
|
||||
` Supported runtimes: ${nodeSupport.supportedDisplay}\n` +
|
||||
` Recommended: Node.js ${nodeSupport.recommendedVersion}\n` +
|
||||
` If you installed OmniRoute globally, run \`node -v\` and confirm \`omniroute\` is not resolving to\n` +
|
||||
` a stale/distro-packaged \`nodejs\` binary (e.g. /usr/bin/node) instead of the version you expect —\n` +
|
||||
` that mismatch is the most common cause even when package.json's engines range is correct.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// MCP stdio transport uses stdout exclusively for JSON-RPC messages. Redirect
|
||||
// console.log/warn to stderr before anything else runs — including the tsx/esm and
|
||||
// polyfill imports below, since those (and their transitive module graphs, e.g. DB
|
||||
|
||||
1
changelog.d/features/13578-proxy-skip-recently-failed.md
Normal file
1
changelog.d/features/13578-proxy-skip-recently-failed.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxies):** proxy pools and opencode's per-account rotation stop re-serving a proxy that just failed (refused TCP probe, or a 429 through it) for a period that doubles on each repeat up to a cap, without writing any proxy status; with every candidate set aside the choice is unchanged. Opt-in via the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: selection unchanged) ([#13578](https://github.com/diegosouzapw/OmniRoute/pull/13578)) — thanks @maxmad64bis
|
||||
1
changelog.d/features/13580-proxy-log-upstream-status.md
Normal file
1
changelog.d/features/13580-proxy-log-upstream-status.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxy-logs):** proxy log rows keep the HTTP status the provider actually returned (`upstream_status`, null when no response arrived), so a throttled egress IP (429), a refused one (403) and a provider outage (500) are no longer the same "error" line, and a 429 generated locally is no longer mistaken for one from the provider ([#13580](https://github.com/diegosouzapw/OmniRoute/pull/13580)) — thanks @maxmad64bis
|
||||
1
changelog.d/features/13581-pool-egress-observation.md
Normal file
1
changelog.d/features/13581-pool-egress-observation.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxies):** the proxy pool editor shows, for the last 24 h, how many distinct egress IPs actually served the pool's members, how many connections went through them and the most seen behind one IP, read from the proxy log through a separate route so it can never break the pool screen; opt-in with the `PROXY_POOL_EGRESS_OBSERVATION` feature flag (default off) ([#13581](https://github.com/diegosouzapw/OmniRoute/pull/13581)) — thanks @maxmad64bis
|
||||
1
changelog.d/features/13602-pool-skips-refused-member.md
Normal file
1
changelog.d/features/13602-pool-skips-refused-member.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxies):** a proxy pool stops re-serving a member the provider just refused through it and tries another member instead, reusing the existing skip cooldown; a later success through the member clears it. Opt-in with the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: pool selection unchanged) ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602)) — thanks @maxmad64bis
|
||||
1
changelog.d/features/13660-i18n-batch-sa.md
Normal file
1
changelog.d/features/13660-i18n-batch-sa.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(i18n):** 8 new locales — Kannada (`kn`), Malayalam (`ml`), Odia (`or`), Punjabi (`pa`), Nepali (`ne`), Sinhala (`si`), Burmese (`my`), Khmer (`km`) — across the dashboard, docs mirrors, CLI, README and the site (59 locales). The translator now restores the ICU literal escape around angle placeholders and splits oversized docs sections before translating. (#0000)
|
||||
1
changelog.d/features/13727-i18n-batch-af.md
Normal file
1
changelog.d/features/13727-i18n-batch-af.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(i18n):** 7 new locales — Hausa (`ha`), Yoruba (`yo`), Igbo (`ig`), Amharic (`am`), Uzbek (`uz`), Georgian (`ka`), Armenian (`hy`) — across the dashboard, docs mirrors, CLI, README and the site (66 locales, the full planned expansion from 43). (#13727)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(api):** `/v1/files` and `/v1/batches` now enforce one ownership rule everywhere — a dashboard session is the instance operator, an API key acts on its own records only, and a record with no owner is denied to every non-session caller. Previously a file or batch whose `api_key_id` was null (a dashboard-session or anonymous upload, or a batch artifact inheriting one) could be read, downloaded, deleted, cancelled or used as a batch input by any other key or by an unauthenticated caller (GHSA-2jm2-mpx8-6523), and `GET /v1/files` / `GET /v1/batches` returned every tenant's records to an anonymous or invalid-bearer caller under the default `REQUIRE_API_KEY=false` (GHSA-m3hp-hq9g-fpmv) — both lists now fail closed with a `401`, and only a dashboard session without a key reads the whole instance. The same shared rule lets the dashboard cancel any batch, not just unowned ones. Behaviour change: the anonymous upload → batch → download flow no longer works without an API key, since a null owner cannot be attributed. Subsumes [#13683](https://github.com/diegosouzapw/OmniRoute/pull/13683) — thanks @hartmark
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(auth):** closed the JWT_SECRET bootstrap chain (GHSA-7pq4-8pvv-rx7r). The fresh-install bootstrap gate in `isAuthRequired()` now decides "loopback" from the trusted peer — the token-stamped real TCP peer the custom server writes, the pipeline's own locality verdict, or a real socket — and never from the client-controlled `Host` / `nextUrl.hostname` whenever a stamping server is in front (every supported runtime), so `Host: localhost` from a remote address no longer opens the window; the anonymous first-password write (`POST /api/settings/require-login`) is under the same loopback constraint instead of being open to every network peer, and `managementPolicy` hands its `peerContext` verdict down explicitly. `/api/settings/obsidian` (incl. `/webdav`, which mints reusable WebDAV Basic credentials for a caller-chosen root served before Next.js) joined `ALWAYS_PROTECTED_API_PATHS`, and `enableObsidianVaultSync()` refuses a vault that is, sits inside, or contains the data directory (realpath-resolved), so the WebDAV file service can no longer be pointed at `server.env` / `storage.sqlite`
|
||||
@@ -0,0 +1 @@
|
||||
- fix(routing): stop a round-robin combo's "opencode" targets from collapsing onto the opencode-zen connection (#11912)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(routing): stop the reactive-compaction debug log from lying when compression is globally disabled (#11977)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(electron): relativize standalone-bundle symlink targets so Stage 8 manifest verification stops failing on Windows (#11979)
|
||||
1
changelog.d/fixes/12061-compression-studio-run-error.md
Normal file
1
changelog.d/fixes/12061-compression-studio-run-error.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): surface a visible error when Compression Studio's combined preview run fails (#12061)
|
||||
1
changelog.d/fixes/12063-compression-profile-header.md
Normal file
1
changelog.d/fixes/12063-compression-profile-header.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): make the compression "Effective pipeline" preview honor the active profile and warn when the master switch is off (#12063)
|
||||
1
changelog.d/fixes/12072-tinycms-dom-shim-leak.md
Normal file
1
changelog.d/fixes/12072-tinycms-dom-shim-leak.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): scope TinyCMS Web signer's DOM shims to each call instead of leaking them for the process lifetime, and surface a clean HTTP status on a non-JSON interception-toggles error (#12072)
|
||||
1
changelog.d/fixes/12111-vision-bridge-model-lockout.md
Normal file
1
changelog.d/fixes/12111-vision-bridge-model-lockout.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(guardrails): stop Vision Bridge from re-selecting a model locked after a 404 (#12111)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(sse): require Responses-shaped body before native OpenAI-compatible passthrough (#12129)
|
||||
1
changelog.d/fixes/12132-minimax-m3-adaptive-thinking.md
Normal file
1
changelog.d/fixes/12132-minimax-m3-adaptive-thinking.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): minimax-m3 now collapses manual thinking.type:"enabled" to adaptive, preventing upstream 400 (2013) (#12132)
|
||||
1
changelog.d/fixes/12172-model-id-collision-chat-image.md
Normal file
1
changelog.d/fixes/12172-model-id-collision-chat-image.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): scope model visibility overrides by modality so hiding a Chat model no longer hides an identically-ID'd Image/Embeddings/etc. model (#12172)
|
||||
1
changelog.d/fixes/12173-lmstudio-multi-account.md
Normal file
1
changelog.d/fixes/12173-lmstudio-multi-account.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): scope local-provider apiKey dedup to matching base URL so LM Studio/Ollama-style connections support multiple accounts (#12173)
|
||||
1
changelog.d/fixes/12190-trae-referer-401.md
Normal file
1
changelog.d/fixes/12190-trae-referer-401.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): refresh Trae's stale Referer/Origin and forward user timezone so imported connections stop failing with 401 (#12190)
|
||||
1
changelog.d/fixes/12196-opencode-go-gpt56luna.md
Normal file
1
changelog.d/fixes/12196-opencode-go-gpt56luna.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): route opencode-go/gpt-5.6-luna to /responses instead of /chat/completions (#12196)
|
||||
1
changelog.d/fixes/12251-extra-upstream-headers-delete.md
Normal file
1
changelog.d/fixes/12251-extra-upstream-headers-delete.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): allow deleting the last extra-upstream-header row even when invalid (#12251)
|
||||
1
changelog.d/fixes/12296-node-runtime-guard-early.md
Normal file
1
changelog.d/fixes/12296-node-runtime-guard-early.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cli): run the Node.js runtime compatibility guard before the heavy `tsx/esm` + Commander import chain so an unsupported runtime gets a clear message instead of a raw `Invalid regular expression flags` crash (#12296)
|
||||
1
changelog.d/fixes/12298-provider-node-delete-refresh.md
Normal file
1
changelog.d/fixes/12298-provider-node-delete-refresh.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): refresh the providers list after deleting a compatible provider node (#12298)
|
||||
1
changelog.d/fixes/12341-budget-alias-auto.md
Normal file
1
changelog.d/fixes/12341-budget-alias-auto.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(usage): fail closed on API-key budget enforcement when a provider's `auto` routing alias has no pricing row, instead of silently counting it as $0 (#12341)
|
||||
1
changelog.d/fixes/12398-claude-truly-empty-stream.md
Normal file
1
changelog.d/fixes/12398-claude-truly-empty-stream.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): surface an error instead of a silent empty 200 when a Claude stream closes with zero bytes (#12398)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(oauth): warn before the dead localhost:8080 redirect in antigravity/gemini `oauth start` (#12413)
|
||||
1
changelog.d/fixes/12517-devin-cli-sse-double-close.md
Normal file
1
changelog.d/fixes/12517-devin-cli-sse-double-close.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): stop devin-cli spawn error from double-closing the SSE controller (#12517)
|
||||
1
changelog.d/fixes/12568-compose-loopback-bind.md
Normal file
1
changelog.d/fixes/12568-compose-loopback-bind.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(docker): default docker-compose app ports (dashboard/API/live-WS) to loopback instead of `0.0.0.0`, closing the anonymous `/v1` LAN/WAN exposure gap left open by `REQUIRE_API_KEY=false` (#12568)
|
||||
1
changelog.d/fixes/12569-webhook-dns-rebinding-ssrf.md
Normal file
1
changelog.d/fixes/12569-webhook-dns-rebinding-ssrf.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): close DNS-rebinding SSRF gap in webhook outbound-URL guard (#12569)
|
||||
1
changelog.d/fixes/12571-vnc-cdp-bridge-auth.md
Normal file
1
changelog.d/fixes/12571-vnc-cdp-bridge-auth.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(docker): require a per-session token on the VNC browser CDP bridge and isolate it on a dedicated Docker network (#12571)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(open-sse): write Adobe Firefly session tokens and cookie jars with 0700/0600 permissions instead of the process umask (#12572)
|
||||
1
changelog.d/fixes/12573-gemini-cors-wildcard.md
Normal file
1
changelog.d/fixes/12573-gemini-cors-wildcard.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): remove hardcoded wildcard CORS in openai-to-gemini-sse.ts so the centralized fail-closed CORS gate is the sole source of `Access-Control-Allow-Origin` (#12573)
|
||||
1
changelog.d/fixes/12574-elevenlabs-policy-enforcement.md
Normal file
1
changelog.d/fixes/12574-elevenlabs-policy-enforcement.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): enforce API key policy (budget/rate-limit/schedule/endpoint scoping) on the ElevenLabs speech-to-text, text-to-speech and voices proxy routes (#12574)
|
||||
1
changelog.d/fixes/12577-huggingchat-buffer-cap.md
Normal file
1
changelog.d/fixes/12577-huggingchat-buffer-cap.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): cap HuggingChat NDJSON body size and bound the read loop with the fetch timeout so a stalled or hostile upstream cannot buffer unbounded memory (#12577)
|
||||
1
changelog.d/fixes/12578-cliproxyapi-loopback-bind.md
Normal file
1
changelog.d/fixes/12578-cliproxyapi-loopback-bind.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(docker): scope the cliproxyapi/qdrant/bifrost sidecars to loopback by default and forward `CLIPROXYAPI_MANAGEMENT_KEY` into the cliproxyapi container so its management API is not left both unauthenticated and LAN-published (#12578)
|
||||
1
changelog.d/fixes/12579-db-export-tempdir-mkdtemp.md
Normal file
1
changelog.d/fixes/12579-db-export-tempdir-mkdtemp.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): create DB export temp paths with `fs.mkdtempSync` instead of predictable timestamps (#12579)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(providers): send `x-api-key` instead of `Authorization: Bearer` for OpenCode Zen's `/v1/responses` endpoint (Muse Spark Contributor models), fixing a 401 on OmniRoute's auth header (#12633)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(sse): surface the actionable "Auggie CLI not found" message when the shell reports a missing `auggie` binary via exit code instead of a spawn error (#12645)
|
||||
1
changelog.d/fixes/12656-tls-wreq-first-byte-watchdog.md
Normal file
1
changelog.d/fixes/12656-tls-wreq-first-byte-watchdog.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): add first-byte watchdog to the TLS-fingerprint transport so a stalled wreq body falls back instead of hanging for minutes (#12656)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(sse): exempt tiny-budget reasoning probes from combo quality failure and surface persisted-cooldown skips in ALL_TARGETS_SKIPPED diagnostics (#12659)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(models): declare the real ~1M contextLength for OpenCode Zen's Muse Spark 1.2 models instead of falling back to the 200000 provider default (#12681)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(oauth): align codebuddy-cn OAuth User-Agent with the chat/usage CLI version to avoid WAF false positives (#12702)
|
||||
1
changelog.d/fixes/12709-guest-import-settings.md
Normal file
1
changelog.d/fixes/12709-guest-import-settings.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): surface an authentication-required banner instead of silently blanking database settings for a guest session (#12709)
|
||||
4
changelog.d/fixes/12732-basereds-orphans-v3851.md
Normal file
4
changelog.d/fixes/12732-basereds-orphans-v3851.md
Normal file
@@ -0,0 +1,4 @@
|
||||
- **fix(sse):** the provider execution pipeline keeps the upstream error `code`/`type` again — both were lost when the non-streaming leg was extracted, so a config-class `422` (Antigravity missing project) degraded into a generic account cooldown instead of reaching the gate that recognises it ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))
|
||||
- **fix(db):** a legacy `call_logs` table no longer aborts startup — the provider-stats index is created after the column healing runs, not before it, so an install predating `request_type` opens instead of failing with `no such column` ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))
|
||||
- **fix(catalog):** a malformed row in the operator-writable custom-models blob no longer kills every `auto/*` pool ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))
|
||||
- **fix(tests):** realign the guards that the same day's merges left asserting the old behavior — the provider-count total after GreenPT and EURouter, the GLM stream buffer slot that `#12925` turned into a declared parameter, and the injection scan bound that `#13104` reshaped from a head-only window into head-plus-tail ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(models):** a cold `GET /v1/models` on a large deployment no longer blocks the event loop for about a second at a time or overruns the 8s cold-build bound: since #12046 the built-in `auto/*` combos resolved catalog metadata for every target of every combo without memoizing or yielding, and they all draw on the same candidate pool, so 720 synced models took the build from ~4s to ~18s. Each distinct target is now resolved once per build, with a yield between misses ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))
|
||||
1
changelog.d/fixes/12732-quota-share-unweighted-drr.md
Normal file
1
changelog.d/fixes/12732-quota-share-unweighted-drr.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(combo):** a `quota-share` combo whose steps carry no weight now rotates across its targets again instead of sending every request to the first one — the resolver turns an unset weight into 0 and #10881 made 0 mean "disabled", so an all-unweighted combo had no quanta and fell back to definition order; an explicit 0 still disables a target next to weighted siblings ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732))
|
||||
1
changelog.d/fixes/12734-semantic-cache-tool-choice.md
Normal file
1
changelog.d/fixes/12734-semantic-cache-tool-choice.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cache): fold tool_choice/tools/response_format into the semantic cache signature so a cached tool_calls response can no longer be replayed for a request whose tool policy forbids it (#12734)
|
||||
1
changelog.d/fixes/12745-memory-rerank-loopback-auth.md
Normal file
1
changelog.d/fixes/12745-memory-rerank-loopback-auth.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(memory): authenticate the internal /v1/rerank loopback call so memory reranking no longer silently degrades to unranked order when REQUIRE_API_KEY=true (#12745)
|
||||
1
changelog.d/fixes/12749-ollama-cloud-usage-cookie.md
Normal file
1
changelog.d/fixes/12749-ollama-cloud-usage-cookie.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): parse Ollama Cloud's current usage markup (`$X of $Y used` aria-label, nested width style) (#12749)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(cli): setup-opencode no longer sends an active context's management token to `/v1/models` when `--api-key`/`OMNIROUTE_API_KEY` is supplied — an explicit flag or the env var now always outranks the context's token, and the flag itself is no longer swallowed by the parent program's global `--api-key` option (#12783)
|
||||
1
changelog.d/fixes/12784-arcee-ai-provider-registry.md
Normal file
1
changelog.d/fixes/12784-arcee-ai-provider-registry.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): register Arcee AI in the executor provider registry so requests reach api.arcee.ai instead of silently falling back to OpenAI (#12784)
|
||||
1
changelog.d/fixes/12800-cliproxyapi-unknown-provider.md
Normal file
1
changelog.d/fixes/12800-cliproxyapi-unknown-provider.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(routing): recognize CLIProxyAPI's 'unknown provider for model' 400 as fallback-worthy (#12800)
|
||||
1
changelog.d/fixes/12849-nvidia-stale-synced-catalog.md
Normal file
1
changelog.d/fixes/12849-nvidia-stale-synced-catalog.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(nvidia): fail open when a synced model catalog goes stale instead of gating forever (#12849)
|
||||
1
changelog.d/fixes/12888-a2a-dashboard-auth.md
Normal file
1
changelog.d/fixes/12888-a2a-dashboard-auth.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(a2a): accept the dashboard's own session cookie on /a2a so "Run message/send" no longer fails with "Unauthorized: missing or invalid API key" (#12888)
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(api):** `DELETE /v1/batches/delete-completed` now sweeps only the calling API key's own completed batches (batches with no owner stay out of a key-scoped sweep on purpose), with an explicit instance-wide mode reserved for authenticated dashboard sessions, a 401 for a presented key that is unknown, revoked, deactivated, banned or expired (never falling through to the session branch), audit logging of every sweep, a sanitized 500 on failure, an owner-scoped file half (a key-scoped sweep never nulls a file another tenant owns) and an atomic sweep — chunked in 200-batch transactions in instance mode — so a mid-way error never leaves a batch pointing at a nulled file (GHSA-wvxc-jp3v-5mg5) ([#12969](https://github.com/diegosouzapw/OmniRoute/pull/12969))
|
||||
1
changelog.d/fixes/13153-early-eof-sibling-failover.md
Normal file
1
changelog.d/fixes/13153-early-eof-sibling-failover.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(sse):** fail over once to a sibling connection on stream early EOF (the original `STREAM_EARLY_EOF` 502 is kept when no sibling can serve the request), gated behind `STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED` (default off) ([#13153](https://github.com/diegosouzapw/OmniRoute/pull/13153)) — thanks @maxmad64bis
|
||||
1
changelog.d/fixes/13217-combo-dead-keys.md
Normal file
1
changelog.d/fixes/13217-combo-dead-keys.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(combos):** stop dropping live keys and persisting dead ones ([#13217](https://github.com/diegosouzapw/OmniRoute/pull/13217)) — thanks @maxmad64bis
|
||||
1
changelog.d/fixes/13218-wal-busy-counter.md
Normal file
1
changelog.d/fixes/13218-wal-busy-counter.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(db):** the WAL checkpoint busy counter reported by `/api/monitoring/health` now survives restarts — busy checkpoints are counted in memory and persisted from the next clean maintenance tick or at shutdown, never with a write while the database is contended ([#13218](https://github.com/diegosouzapw/OmniRoute/pull/13218)) — thanks @maxmad64bis
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user